Security fixes for 2026.10.29.1 and 27.1-rc.3 #231
No files matched your search
Generated
-2
@@ -1032,8 +1032,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "calcard"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c601473ec15a875626bce73db1a1f0fc81e9c949ca25f1463b714947c0a70a1f"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"chrono",
|
||||
|
||||
@@ -93,12 +93,17 @@ rpath = false
|
||||
# vendor/sieve-rs is the published 0.7.3 with those renamed; see its
|
||||
# VENDORED.md. Re-vendor when the version in Cargo.lock moves.
|
||||
#
|
||||
# inbuxa: vendor/calcard is the published 0.3.14 with one work budget shared
|
||||
# by all the recurrence rules an expansion runs; see its VENDORED.md.
|
||||
# Re-vendor when the version in Cargo.lock moves.
|
||||
#
|
||||
# inbuxa: hickory and opentelemetry are upstream's patched branches, vendored
|
||||
# at the commits upstream pins so builds never fetch from upstream's
|
||||
# repositories; see vendor/hickory-dns/VENDORED.md and
|
||||
# vendor/opentelemetry-rust/VENDORED.md.
|
||||
[patch.crates-io]
|
||||
sieve-rs = { path = "vendor/sieve-rs" }
|
||||
calcard = { path = "vendor/calcard" }
|
||||
hickory-proto = { path = "vendor/hickory-dns/hickory-proto" }
|
||||
hickory-net = { path = "vendor/hickory-dns/hickory-net" }
|
||||
hickory-resolver = { path = "vendor/hickory-dns/hickory-resolver" }
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
*/
|
||||
|
||||
use crate::Directory;
|
||||
use crate::backend::oidc::lookup::fetch_jwks_keys;
|
||||
use crate::backend::oidc::lookup::{fetch_jwks_keys, read_body};
|
||||
use crate::backend::oidc::{
|
||||
CachedKey, DiscoveryDocument, JwksCache, OidcConfig, OidcDiscovery, OidcError, OpenIdDirectory,
|
||||
CachedKey, DiscoveryDocument, Introspection, JwksCache, OidcConfig, OidcDiscovery, OidcError,
|
||||
OpaqueTokens, OpenIdDirectory,
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
use registry::schema::structs;
|
||||
use registry::schema::{enums::OidcOpaqueTokens, structs};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
@@ -22,8 +23,57 @@ use utils::Client;
|
||||
const DISCOVERY_RETRY_FOR: Duration = Duration::from_secs(30);
|
||||
const DISCOVERY_RETRY_INTERVAL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// inbuxa: http-directory-services-13: what a bearer token that isn't a JWT
|
||||
/// signs in by. Unset, as on a new directory: introspection when an
|
||||
/// introspection endpoint is set, else nothing. A directory saved before the
|
||||
/// setting existed reads as userinfo, which it always used.
|
||||
pub fn opaque_token_policy(config: &structs::OidcDirectory) -> OidcOpaqueTokens {
|
||||
config
|
||||
.opaque_tokens
|
||||
.unwrap_or(if config.introspection_url.is_some() {
|
||||
OidcOpaqueTokens::Introspection
|
||||
} else {
|
||||
OidcOpaqueTokens::Reject
|
||||
})
|
||||
}
|
||||
|
||||
impl OpenIdDirectory {
|
||||
pub async fn open(config: structs::OidcDirectory) -> Result<Directory, String> {
|
||||
// inbuxa: http-directory-services-13: only a directory saved before
|
||||
// the audience was required has none; it keeps working, with a
|
||||
// warning every time it loads
|
||||
if config.require_audience.is_none() {
|
||||
trc::event!(
|
||||
Auth(AuthEvent::Warning),
|
||||
Url = config.issuer_url.clone(),
|
||||
Details = config.description.clone(),
|
||||
Reason = "No required audience is set, so tokens this provider issued to any \
|
||||
other application sign in. Set requireAudience to the client ID \
|
||||
registered for this server; a later release will require it."
|
||||
);
|
||||
}
|
||||
|
||||
let opaque_tokens = match opaque_token_policy(&config) {
|
||||
OidcOpaqueTokens::Reject => OpaqueTokens::Reject,
|
||||
OidcOpaqueTokens::Userinfo => OpaqueTokens::Userinfo,
|
||||
OidcOpaqueTokens::Introspection => OpaqueTokens::Introspection(Introspection {
|
||||
url: config.introspection_url,
|
||||
client_id: config.introspection_client_id.ok_or_else(|| {
|
||||
"Opaque tokens are checked by introspection, which needs a client ID"
|
||||
.to_string()
|
||||
})?,
|
||||
client_secret: config
|
||||
.introspection_client_secret
|
||||
.secret()
|
||||
.await?
|
||||
.map(|secret| secret.into_owned())
|
||||
.ok_or_else(|| {
|
||||
"Opaque tokens are checked by introspection, which needs a client secret"
|
||||
.to_string()
|
||||
})?,
|
||||
}),
|
||||
};
|
||||
|
||||
Self::new(OidcConfig {
|
||||
issue_url: config.issuer_url,
|
||||
require_aud: config.require_audience,
|
||||
@@ -32,13 +82,14 @@ impl OpenIdDirectory {
|
||||
claim_name: config.claim_name,
|
||||
claim_groups: config.claim_groups,
|
||||
default_domain: config.username_domain,
|
||||
opaque_tokens,
|
||||
})
|
||||
.await
|
||||
.map(Directory::OpenId)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
pub async fn new(config: OidcConfig) -> Result<Self, OidcError> {
|
||||
pub async fn new(mut config: OidcConfig) -> Result<Self, OidcError> {
|
||||
let http = utils::http::http_client_builder(false)
|
||||
.user_agent("inbuxa/1.0") // types::brand!(); this crate does not depend on types
|
||||
.timeout(Duration::from_secs(30))
|
||||
@@ -64,6 +115,19 @@ impl OpenIdDirectory {
|
||||
}
|
||||
};
|
||||
|
||||
// inbuxa: http-directory-services-13
|
||||
if let OpaqueTokens::Introspection(introspection) = &mut config.opaque_tokens
|
||||
&& introspection.url.is_none()
|
||||
{
|
||||
introspection.url = Some(document.introspection_endpoint.clone().ok_or_else(|| {
|
||||
OidcError::Config(
|
||||
"Opaque tokens are checked by introspection, but no introspection endpoint \
|
||||
is set and the provider's discovery document names none"
|
||||
.to_string(),
|
||||
)
|
||||
})?);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
discovery: OidcDiscovery {
|
||||
url: config.issue_url.clone(),
|
||||
@@ -86,16 +150,16 @@ impl OpenIdDirectory {
|
||||
"{}/.well-known/openid-configuration",
|
||||
config.issue_url.trim_end_matches('/')
|
||||
);
|
||||
let discovery_bytes = http
|
||||
.get(&discovery_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OidcError::Network(format!("Discovery fetch failed: {e}")))?
|
||||
.error_for_status()
|
||||
.map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))?;
|
||||
let discovery_bytes = read_body(
|
||||
http.get(&discovery_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OidcError::Network(format!("Discovery fetch failed: {e}")))?
|
||||
.error_for_status()
|
||||
.map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))?,
|
||||
"Discovery",
|
||||
)
|
||||
.await?;
|
||||
let discovery: DiscoveryDocument = serde_json::from_slice(&discovery_bytes)
|
||||
.map_err(|e| OidcError::Provider(format!("Discovery JSON parse error: {e}")))?;
|
||||
|
||||
@@ -177,3 +241,35 @@ impl OpenIdDirectory {
|
||||
Ok((discovery, keys))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_directories_refuse_opaque_tokens_unless_they_name_an_endpoint() {
|
||||
let new = structs::OidcDirectory::default();
|
||||
assert_eq!(opaque_token_policy(&new), OidcOpaqueTokens::Reject);
|
||||
assert_eq!(
|
||||
opaque_token_policy(&structs::OidcDirectory {
|
||||
introspection_url: Some("https://idp.example.org/introspect".to_string()),
|
||||
..new.clone()
|
||||
}),
|
||||
OidcOpaqueTokens::Introspection
|
||||
);
|
||||
for chosen in [
|
||||
OidcOpaqueTokens::Reject,
|
||||
OidcOpaqueTokens::Userinfo,
|
||||
OidcOpaqueTokens::Introspection,
|
||||
] {
|
||||
assert_eq!(
|
||||
opaque_token_policy(&structs::OidcDirectory {
|
||||
opaque_tokens: Some(chosen),
|
||||
introspection_url: Some("https://idp.example.org/introspect".to_string()),
|
||||
..new.clone()
|
||||
}),
|
||||
chosen
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,12 @@
|
||||
|
||||
use crate::{
|
||||
Account, Credentials,
|
||||
backend::oidc::{CachedKey, OidcError, OpenIdDirectory},
|
||||
backend::oidc::{CachedKey, Introspection, OidcError, OpaqueTokens, OpenIdDirectory},
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
use jsonwebtoken::{
|
||||
Algorithm, DecodingKey, Header, Validation, decode, decode_header,
|
||||
errors::ErrorKind,
|
||||
jwk::{self, JwkSet},
|
||||
};
|
||||
use reqwest::Client;
|
||||
@@ -21,6 +22,14 @@ use std::time::Instant;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use trc::AuthEvent;
|
||||
|
||||
/// How often the provider's keys may be fetched again for a token they don't
|
||||
/// verify.
|
||||
const KEY_REFRESH_INTERVAL: Duration = Duration::from_secs(300);
|
||||
|
||||
/// inbuxa: http-directory-services-13: what a provider sends back is read up
|
||||
/// to this much.
|
||||
const MAX_RESPONSE_SIZE: usize = 1024 * 1024;
|
||||
|
||||
impl OpenIdDirectory {
|
||||
pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result<Account> {
|
||||
match credentials {
|
||||
@@ -59,81 +68,211 @@ impl OpenIdDirectory {
|
||||
));
|
||||
}
|
||||
|
||||
let candidates = self.get_key(header.kid.as_deref()).await?;
|
||||
let mut last_err = None;
|
||||
for cached in &candidates {
|
||||
let dk = &cached.decoding_key;
|
||||
let alg = cached.algorithm;
|
||||
let mut validation = Validation::new(alg);
|
||||
let mut candidates = self.get_key(header.kid.as_deref()).await?;
|
||||
let mut refreshed = false;
|
||||
loop {
|
||||
let mut last_err = None;
|
||||
// inbuxa: whether every key failed only for being the wrong key
|
||||
let mut wrong_keys = true;
|
||||
for cached in &candidates {
|
||||
let dk = &cached.decoding_key;
|
||||
let alg = cached.algorithm;
|
||||
let mut validation = Validation::new(alg);
|
||||
|
||||
if let Some(aud) = &self.config.require_aud {
|
||||
validation.set_audience(&[aud]);
|
||||
} else {
|
||||
validation.validate_aud = false;
|
||||
if let Some(aud) = &self.config.require_aud {
|
||||
validation.set_audience(&[aud]);
|
||||
} else {
|
||||
validation.validate_aud = false;
|
||||
}
|
||||
|
||||
validation.set_issuer(&[&self.discovery.document.issuer]);
|
||||
validation.leeway = 60;
|
||||
|
||||
match decode::<serde_json::Value>(token, dk, &validation) {
|
||||
Ok(token_data) => {
|
||||
if self.config.require_aud.is_some()
|
||||
&& token_data.claims.get("aud").is_none()
|
||||
{
|
||||
last_err = Some(jsonwebtoken::errors::Error::from(
|
||||
jsonwebtoken::errors::ErrorKind::InvalidAudience,
|
||||
));
|
||||
wrong_keys = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
check_token_type(&token_data.claims)?;
|
||||
self.validate_scopes(&token_data.claims)?;
|
||||
|
||||
return self.account_from_claims(token, token_data.claims).await;
|
||||
}
|
||||
Err(e) => {
|
||||
wrong_keys &= matches!(
|
||||
e.kind(),
|
||||
ErrorKind::InvalidSignature | ErrorKind::InvalidAlgorithm
|
||||
);
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validation.set_issuer(&[&self.discovery.document.issuer]);
|
||||
validation.leeway = 60;
|
||||
// inbuxa: http-directory-services-13: a token without a kid that
|
||||
// no cached key verifies may be signed with a key the provider
|
||||
// rotated in, so the keys are fetched again, once, as for an
|
||||
// unknown kid (and as rarely)
|
||||
if header.kid.is_none()
|
||||
&& !refreshed
|
||||
&& wrong_keys
|
||||
&& let Some(keys) = self.refresh_keys().await
|
||||
{
|
||||
candidates = keys;
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
match decode::<serde_json::Value>(token, dk, &validation) {
|
||||
Ok(token_data) => {
|
||||
if self.config.require_aud.is_some() && token_data.claims.get("aud").is_none() {
|
||||
last_err = Some(jsonwebtoken::errors::Error::from(
|
||||
jsonwebtoken::errors::ErrorKind::InvalidAudience,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
return Err(OidcError::TokenValidation(format!(
|
||||
"JWT validation failed: {}",
|
||||
last_err.map(|e| e.to_string()).unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
check_token_type(&token_data.claims)?;
|
||||
self.validate_scopes(&token_data.claims)?;
|
||||
/// inbuxa: the account a validated token's claims name, with what they
|
||||
/// lack taken from the user info endpoint (shared by JWTs and
|
||||
/// introspected tokens).
|
||||
async fn account_from_claims(
|
||||
&self,
|
||||
token: &str,
|
||||
mut claims: serde_json::Value,
|
||||
) -> Result<Account, OidcError> {
|
||||
let missing_username = is_claim_missing(&claims, Some(&self.config.claim_email));
|
||||
let jwt_email = self.resolve_email(&claims).ok();
|
||||
let missing_profile = is_claim_missing(&claims, self.config.claim_name.as_ref())
|
||||
|| is_claim_missing(&claims, self.config.claim_groups.as_ref());
|
||||
|
||||
let mut claims = token_data.claims;
|
||||
let missing_username =
|
||||
is_claim_missing(&claims, Some(&self.config.claim_email));
|
||||
let jwt_email = self.resolve_email(&claims).ok();
|
||||
let missing_profile =
|
||||
is_claim_missing(&claims, self.config.claim_name.as_ref())
|
||||
|| is_claim_missing(&claims, self.config.claim_groups.as_ref());
|
||||
|
||||
if jwt_email.is_none() || missing_username || missing_profile {
|
||||
match self.fetch_userinfo(token).await {
|
||||
Ok(userinfo) => {
|
||||
if let (Some(base), Value::Object(extra)) =
|
||||
(claims.as_object_mut(), userinfo)
|
||||
{
|
||||
for (key, value) in extra {
|
||||
if base.get(&key).is_none_or(Value::is_null) {
|
||||
base.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if jwt_email.is_none() || missing_username || missing_profile {
|
||||
match self.fetch_userinfo(token).await {
|
||||
Ok(userinfo) => {
|
||||
if let (Some(base), Value::Object(extra)) = (claims.as_object_mut(), userinfo) {
|
||||
for (key, value) in extra {
|
||||
if base.get(&key).is_none_or(Value::is_null) {
|
||||
base.insert(key, value);
|
||||
}
|
||||
Err(err) if jwt_email.is_none() => return Err(err),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let email = match jwt_email {
|
||||
Some(email) if !missing_username => email,
|
||||
_ => self.resolve_email(&claims)?,
|
||||
};
|
||||
return self.build_account(email, &claims);
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
}
|
||||
Err(err) if jwt_email.is_none() => return Err(err),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
Err(OidcError::TokenValidation(format!(
|
||||
"JWT validation failed: {}",
|
||||
last_err.map(|e| e.to_string()).unwrap_or_default()
|
||||
)))
|
||||
let email = match jwt_email {
|
||||
Some(email) if !missing_username => email,
|
||||
_ => self.resolve_email(&claims)?,
|
||||
};
|
||||
self.build_account(email, &claims)
|
||||
}
|
||||
|
||||
async fn authenticate_opaque(&self, token: &str) -> Result<Account, OidcError> {
|
||||
let claims = self.fetch_userinfo(token).await?;
|
||||
self.build_account(self.resolve_email(&claims)?, &claims)
|
||||
// inbuxa: http-directory-services-13: by the directory's policy
|
||||
match &self.config.opaque_tokens {
|
||||
OpaqueTokens::Reject => Err(OidcError::TokenValidation(
|
||||
"This directory accepts only JWT access tokens".to_string(),
|
||||
)),
|
||||
OpaqueTokens::Userinfo => {
|
||||
let claims = self.fetch_userinfo(token).await?;
|
||||
self.build_account(self.resolve_email(&claims)?, &claims)
|
||||
}
|
||||
OpaqueTokens::Introspection(introspection) => {
|
||||
let claims = self.introspect(token, introspection).await?;
|
||||
check_token_type(&claims)?;
|
||||
self.validate_scopes(&claims)?;
|
||||
self.account_from_claims(token, claims).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// inbuxa: http-directory-services-13: asks the provider about a token
|
||||
/// (RFC 7662), and returns what it says only for an active token it
|
||||
/// issued, for the required audience when one is set.
|
||||
async fn introspect(
|
||||
&self,
|
||||
token: &str,
|
||||
introspection: &Introspection,
|
||||
) -> Result<serde_json::Value, OidcError> {
|
||||
let url = introspection
|
||||
.url
|
||||
.as_deref()
|
||||
.ok_or_else(|| OidcError::Config("No introspection endpoint is known".to_string()))?;
|
||||
let resp = self
|
||||
.http
|
||||
.post(url)
|
||||
// RFC 6749, section 2.3.1: both form-encoded first
|
||||
.basic_auth(
|
||||
form_encode(&introspection.client_id),
|
||||
Some(form_encode(&introspection.client_secret)),
|
||||
)
|
||||
.header(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
"application/x-www-form-urlencoded",
|
||||
)
|
||||
.body(format!(
|
||||
"token={}&token_type_hint=access_token",
|
||||
form_encode(token)
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OidcError::Network(format!("Introspection request failed: {e}")))?;
|
||||
|
||||
// A refusal here is of this server's client credentials, not the token
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(OidcError::Provider(format!(
|
||||
"Introspection returned HTTP {status}"
|
||||
)));
|
||||
}
|
||||
let bytes = read_body(resp, "Introspection").await?;
|
||||
let claims = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
.map_err(|e| OidcError::Provider(format!("Introspection JSON parse error: {e}")))?;
|
||||
|
||||
if claims.get("active") != Some(&Value::Bool(true)) {
|
||||
return Err(OidcError::AuthorizationFailed(
|
||||
"The provider says the token is not active".to_string(),
|
||||
));
|
||||
}
|
||||
let issuer = self.discovery.document.issuer.trim_end_matches('/');
|
||||
if claims
|
||||
.get("iss")
|
||||
.and_then(|iss| iss.as_str())
|
||||
.is_none_or(|iss| iss.trim_end_matches('/') != issuer)
|
||||
{
|
||||
return Err(OidcError::TokenValidation(
|
||||
"The token was not issued by this directory's provider".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(audience) = &self.config.require_aud {
|
||||
let found = match claims.get("aud") {
|
||||
Some(Value::String(aud)) => aud == audience,
|
||||
Some(Value::Array(auds)) => auds.iter().any(|aud| aud.as_str() == Some(audience)),
|
||||
_ => false,
|
||||
};
|
||||
if !found {
|
||||
return Err(OidcError::TokenValidation(
|
||||
"The token was not issued for the required audience".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(exp) = claims.get("exp").and_then(|exp| exp.as_u64())
|
||||
&& exp + 60
|
||||
< std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |now| now.as_secs())
|
||||
{
|
||||
return Err(OidcError::TokenValidation(
|
||||
"The token has expired".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
async fn get_key(&self, kid: Option<&str>) -> Result<Vec<Arc<CachedKey>>, OidcError> {
|
||||
@@ -145,7 +284,7 @@ impl OpenIdDirectory {
|
||||
return Ok(vec![cached.clone()]);
|
||||
}
|
||||
|
||||
if guard.last_updated.elapsed() < Duration::from_secs(300) {
|
||||
if guard.last_updated.elapsed() < KEY_REFRESH_INTERVAL {
|
||||
return Err(OidcError::TokenValidation("Unknown key id".to_string()));
|
||||
}
|
||||
} else {
|
||||
@@ -184,6 +323,35 @@ impl OpenIdDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
/// inbuxa: every key, fetched again unless that was tried within
|
||||
/// `KEY_REFRESH_INTERVAL`; None when it was, or the fetch failed (a
|
||||
/// failed fetch also waits out the interval, so tokens can't make the
|
||||
/// server hammer a struggling provider).
|
||||
async fn refresh_keys(&self) -> Option<Vec<Arc<CachedKey>>> {
|
||||
{
|
||||
let mut guard = self.cache.write().await;
|
||||
if guard.last_updated.elapsed() < KEY_REFRESH_INTERVAL {
|
||||
return None;
|
||||
}
|
||||
guard.last_updated = Instant::now();
|
||||
}
|
||||
match fetch_jwks_keys(&self.http, &self.discovery.document.jwks_uri).await {
|
||||
Ok(keys) => {
|
||||
let all = keys.values().cloned().collect();
|
||||
self.cache.write().await.keys = keys;
|
||||
Some(all)
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Auth(AuthEvent::Warning),
|
||||
Url = self.discovery.document.jwks_uri.clone(),
|
||||
Reason = format!("JWKS refresh failed: {err}")
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_userinfo(&self, token: &str) -> Result<serde_json::Value, OidcError> {
|
||||
let resp = self
|
||||
.http
|
||||
@@ -195,7 +363,10 @@ impl OpenIdDirectory {
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
let reason = resp.text().await.unwrap_or_default();
|
||||
let reason = read_body(resp, "UserInfo")
|
||||
.await
|
||||
.map(|body| String::from_utf8_lossy(&body).into_owned())
|
||||
.unwrap_or_default();
|
||||
return Err(OidcError::AuthorizationFailed(format!(
|
||||
"Token rejected by UserInfo endpoint with status {status}: {reason}"
|
||||
)));
|
||||
@@ -206,10 +377,7 @@ impl OpenIdDirectory {
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| OidcError::Provider(format!("UserInfo HTTP error: {e}")))?;
|
||||
let bytes = read_body(resp, "UserInfo").await?;
|
||||
|
||||
serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
.map_err(|e| OidcError::Provider(format!("UserInfo JSON parse error: {e}")))
|
||||
@@ -296,16 +464,16 @@ pub(super) async fn fetch_jwks_keys(
|
||||
http: &Client,
|
||||
jwks_uri: &str,
|
||||
) -> Result<AHashMap<String, Arc<CachedKey>>, OidcError> {
|
||||
let jwks_bytes = http
|
||||
.get(jwks_uri)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OidcError::Network(format!("JWKS fetch failed: {e}")))?
|
||||
.error_for_status()
|
||||
.map_err(|e| OidcError::Provider(format!("JWKS HTTP error: {e}")))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| OidcError::Provider(format!("JWKS HTTP error: {e}")))?;
|
||||
let jwks_bytes = read_body(
|
||||
http.get(jwks_uri)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OidcError::Network(format!("JWKS fetch failed: {e}")))?
|
||||
.error_for_status()
|
||||
.map_err(|e| OidcError::Provider(format!("JWKS HTTP error: {e}")))?,
|
||||
"JWKS",
|
||||
)
|
||||
.await?;
|
||||
let jwks: JwkSet = serde_json::from_slice(&jwks_bytes)
|
||||
.map_err(|e| OidcError::Provider(format!("JWKS JSON parse error: {e}")))?;
|
||||
|
||||
@@ -409,6 +577,52 @@ pub(super) async fn fetch_jwks_keys(
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// inbuxa: http-directory-services-13: a response body, refused once it
|
||||
/// passes `MAX_RESPONSE_SIZE` instead of read whole.
|
||||
pub(super) async fn read_body(
|
||||
mut resp: reqwest::Response,
|
||||
what: &str,
|
||||
) -> Result<Vec<u8>, OidcError> {
|
||||
let too_large = || {
|
||||
OidcError::Provider(format!(
|
||||
"{what} response is larger than {MAX_RESPONSE_SIZE} bytes"
|
||||
))
|
||||
};
|
||||
if resp
|
||||
.content_length()
|
||||
.is_some_and(|length| length > MAX_RESPONSE_SIZE as u64)
|
||||
{
|
||||
return Err(too_large());
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
while let Some(chunk) = resp
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| OidcError::Provider(format!("{what} HTTP error: {e}")))?
|
||||
{
|
||||
if body.len() + chunk.len() > MAX_RESPONSE_SIZE {
|
||||
return Err(too_large());
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// inbuxa: application/x-www-form-urlencoded, for the introspection request.
|
||||
fn form_encode(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'*' | b'-' | b'.' | b'_' => {
|
||||
out.push(byte as char)
|
||||
}
|
||||
b' ' => out.push('+'),
|
||||
_ => out.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn extract_scopes(claims: &serde_json::Value) -> Vec<String> {
|
||||
match claims.get("scope") {
|
||||
Some(serde_json::Value::String(s)) => s.split_whitespace().map(|s| s.to_string()).collect(),
|
||||
@@ -482,6 +696,19 @@ MCowBQYDK2VwAyEAXNXjqSjw780GFXxFztdFBUMpxRoFqsYWN55FXnvncCE=
|
||||
-----END PUBLIC KEY-----";
|
||||
|
||||
fn test_directory() -> OpenIdDirectory {
|
||||
test_directory_at("https://idp.example.org")
|
||||
}
|
||||
|
||||
/// The test directory, with its endpoints at `base`.
|
||||
fn test_directory_at(base: &str) -> OpenIdDirectory {
|
||||
test_directory_with(base, None, OpaqueTokens::Reject)
|
||||
}
|
||||
|
||||
fn test_directory_with(
|
||||
base: &str,
|
||||
require_aud: Option<&str>,
|
||||
opaque_tokens: OpaqueTokens,
|
||||
) -> OpenIdDirectory {
|
||||
let mut keys = AHashMap::new();
|
||||
keys.insert(
|
||||
"test".to_string(),
|
||||
@@ -493,22 +720,24 @@ MCowBQYDK2VwAyEAXNXjqSjw780GFXxFztdFBUMpxRoFqsYWN55FXnvncCE=
|
||||
OpenIdDirectory {
|
||||
config: crate::backend::oidc::OidcConfig {
|
||||
issue_url: "https://idp.example.org".to_string(),
|
||||
require_aud: None,
|
||||
require_aud: require_aud.map(str::to_string),
|
||||
require_scopes: Vec::new(),
|
||||
claim_email: "email".to_string(),
|
||||
claim_name: None,
|
||||
claim_groups: None,
|
||||
default_domain: None,
|
||||
opaque_tokens,
|
||||
},
|
||||
discovery: crate::backend::oidc::OidcDiscovery {
|
||||
url: "https://idp.example.org".to_string(),
|
||||
document: crate::backend::oidc::DiscoveryDocument {
|
||||
issuer: "https://idp.example.org".to_string(),
|
||||
jwks_uri: "https://idp.example.org/jwks".to_string(),
|
||||
userinfo_endpoint: "https://idp.example.org/userinfo".to_string(),
|
||||
token_endpoint: "https://idp.example.org/token".to_string(),
|
||||
authorization_endpoint: "https://idp.example.org/auth".to_string(),
|
||||
jwks_uri: format!("{base}/jwks"),
|
||||
userinfo_endpoint: format!("{base}/userinfo"),
|
||||
token_endpoint: format!("{base}/token"),
|
||||
authorization_endpoint: format!("{base}/auth"),
|
||||
end_session_endpoint: None,
|
||||
introspection_endpoint: None,
|
||||
scopes_supported: None,
|
||||
claims_supported: None,
|
||||
code_challenge_methods_supported: None,
|
||||
@@ -543,6 +772,52 @@ MCowBQYDK2VwAyEAXNXjqSjw780GFXxFztdFBUMpxRoFqsYWN55FXnvncCE=
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A provider on 127.0.0.1 that answers every request with what
|
||||
/// `answer` returns for it (the request line, headers and body).
|
||||
async fn provider(answer: impl Fn(&str) -> (u16, String) + Send + Sync + 'static) -> String {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let answer = Arc::new(answer);
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut stream, _)) = listener.accept().await {
|
||||
let answer = answer.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut request = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let read = stream.read(&mut buf).await.unwrap_or(0);
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buf[..read]);
|
||||
if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
let head = String::from_utf8_lossy(&request[..end]).to_lowercase();
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("content-length:"))
|
||||
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
if request.len() >= end + 4 + length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (status, body) = answer(&String::from_utf8_lossy(&request));
|
||||
// No Content-Length: the body ends when the connection does
|
||||
let head = format!(
|
||||
"HTTP/1.1 {status} Status\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
let _ = stream.write_all(head.as_bytes()).await;
|
||||
let _ = stream.write_all(body.as_bytes()).await;
|
||||
let _ = stream.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
base
|
||||
}
|
||||
|
||||
async fn sign_in(directory: &OpenIdDirectory, token: String) -> trc::Result<Account> {
|
||||
directory
|
||||
.authenticate(&Credentials::Bearer {
|
||||
@@ -587,6 +862,249 @@ MCowBQYDK2VwAyEAXNXjqSjw780GFXxFztdFBUMpxRoFqsYWN55FXnvncCE=
|
||||
}
|
||||
}
|
||||
|
||||
// http-directory-services-13: any token the provider issued, to any
|
||||
// application, passes its user info endpoint, so a new directory
|
||||
// refuses tokens that aren't JWTs
|
||||
#[tokio::test]
|
||||
async fn new_directories_refuse_opaque_tokens() {
|
||||
let base = provider(|_| (200, json!({"email": "[email protected]"}).to_string())).await;
|
||||
let directory = test_directory_at(&base);
|
||||
assert!(
|
||||
sign_in(&directory, "an-opaque-token".to_string())
|
||||
.await
|
||||
.is_err(),
|
||||
"an opaque token signed in through the user info endpoint"
|
||||
);
|
||||
|
||||
// ... and a directory saved before the policy keeps doing so
|
||||
let directory = test_directory_with(&base, None, OpaqueTokens::Userinfo);
|
||||
assert_eq!(
|
||||
sign_in(&directory, "an-opaque-token".to_string())
|
||||
.await
|
||||
.map(|account| account.email)
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some("[email protected]")
|
||||
);
|
||||
}
|
||||
|
||||
// http-directory-services-13: introspection signs in only an active
|
||||
// token this provider issued for the required audience
|
||||
#[tokio::test]
|
||||
async fn introspection_checks_what_the_provider_says() {
|
||||
let base = provider(|request| {
|
||||
let (head, body) = request.split_once("\r\n\r\n").unwrap_or_default();
|
||||
// RFC 6749 2.3.1: id and secret form-encoded, then Basic
|
||||
let authorized = head.lines().any(|line| {
|
||||
line.eq_ignore_ascii_case(&format!(
|
||||
"authorization: Basic {}",
|
||||
base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
"inbuxa:se+cret%2F%2B"
|
||||
)
|
||||
))
|
||||
});
|
||||
if !head.starts_with("POST /introspect ") || !authorized {
|
||||
return (401, json!({"error": "invalid_client"}).to_string());
|
||||
}
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let mut claims = json!({
|
||||
"active": true,
|
||||
"iss": "https://idp.example.org",
|
||||
"aud": ["inbuxa", "account"],
|
||||
"exp": now + 300,
|
||||
"email": "[email protected]",
|
||||
});
|
||||
match body
|
||||
.strip_prefix("token=")
|
||||
.and_then(|body| body.split('&').next())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"good%2Btoken" => {}
|
||||
"inactive" => claims = json!({"active": false}),
|
||||
"foreign" => claims["iss"] = json!("https://other.example.org"),
|
||||
"anonymous" => {
|
||||
claims.as_object_mut().unwrap().remove("iss");
|
||||
}
|
||||
"elsewhere" => claims["aud"] = json!("another-app"),
|
||||
"unaddressed" => {
|
||||
claims.as_object_mut().unwrap().remove("aud");
|
||||
}
|
||||
"refresh" => claims["typ"] = json!("Refresh"),
|
||||
"expired" => claims["exp"] = json!(1),
|
||||
_ => claims = json!({"active": "true"}),
|
||||
}
|
||||
(200, claims.to_string())
|
||||
})
|
||||
.await;
|
||||
let introspection = |client_secret: &str| {
|
||||
OpaqueTokens::Introspection(Introspection {
|
||||
url: Some(format!("{base}/introspect")),
|
||||
client_id: "inbuxa".to_string(),
|
||||
client_secret: client_secret.to_string(),
|
||||
})
|
||||
};
|
||||
let directory = test_directory_with(&base, Some("inbuxa"), introspection("se cret/+"));
|
||||
|
||||
assert_eq!(
|
||||
sign_in(&directory, "good+token".to_string())
|
||||
.await
|
||||
.map(|account| account.email)
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some("[email protected]")
|
||||
);
|
||||
for token in [
|
||||
"inactive",
|
||||
"foreign",
|
||||
"anonymous",
|
||||
"elsewhere",
|
||||
"unaddressed",
|
||||
"refresh",
|
||||
"expired",
|
||||
"not-a-boolean",
|
||||
] {
|
||||
let err = sign_in(&directory, token.to_string()).await.unwrap_err();
|
||||
assert!(
|
||||
err.matches(trc::EventType::Auth(AuthEvent::Failed)),
|
||||
"{token}: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// A refusal of the server's own credentials is not the person's fault
|
||||
let directory = test_directory_with(&base, Some("inbuxa"), introspection("wrong"));
|
||||
let err = sign_in(&directory, "good+token".to_string())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.matches(trc::EventType::Auth(AuthEvent::Error)),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A body over 1 MiB that is otherwise a good answer.
|
||||
fn oversized(mut body: serde_json::Value) -> String {
|
||||
body["padding"] = json!("x".repeat(1024 * 1024));
|
||||
body.to_string()
|
||||
}
|
||||
|
||||
// http-directory-services-13: what the provider sends back is read up to
|
||||
// 1 MiB, never whole
|
||||
#[tokio::test]
|
||||
async fn provider_answers_are_capped() {
|
||||
let base = provider(|request| {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let body = if request.starts_with("POST /introspect ") {
|
||||
json!({
|
||||
"active": true,
|
||||
"iss": "https://idp.example.org",
|
||||
"exp": now + 300,
|
||||
"email": "[email protected]",
|
||||
})
|
||||
} else if request.starts_with("GET /jwks ") {
|
||||
json!({"keys": []})
|
||||
} else {
|
||||
json!({"email": "[email protected]"})
|
||||
};
|
||||
(200, oversized(body))
|
||||
})
|
||||
.await;
|
||||
|
||||
let directory = test_directory_with(&base, None, OpaqueTokens::Userinfo);
|
||||
assert!(
|
||||
sign_in(&directory, "an-opaque-token".to_string())
|
||||
.await
|
||||
.is_err(),
|
||||
"user info"
|
||||
);
|
||||
let directory = test_directory_with(
|
||||
&base,
|
||||
None,
|
||||
OpaqueTokens::Introspection(Introspection {
|
||||
url: Some(format!("{base}/introspect")),
|
||||
client_id: "inbuxa".to_string(),
|
||||
client_secret: "secret".to_string(),
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
sign_in(&directory, "an-opaque-token".to_string())
|
||||
.await
|
||||
.is_err(),
|
||||
"introspection"
|
||||
);
|
||||
assert!(
|
||||
fetch_jwks_keys(&Client::new(), &format!("{base}/jwks"))
|
||||
.await
|
||||
.is_err(),
|
||||
"JWKS"
|
||||
);
|
||||
}
|
||||
|
||||
// http-directory-services-13: a token without a kid that no cached key
|
||||
// verifies fetches the JWKS again, as an unknown kid does, so a provider
|
||||
// that rotated its only key keeps working
|
||||
#[tokio::test]
|
||||
async fn tokens_without_a_kid_refresh_the_keys_once() {
|
||||
const ROTATED_KEY: &str = "-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIGJCOx1xrTLtywhRlvhdTe5Sm7UNQlVFXzqomt76r12T
|
||||
-----END PRIVATE KEY-----";
|
||||
let fetches = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counter = fetches.clone();
|
||||
let base = provider(move |_| {
|
||||
counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let key = json!({
|
||||
"kty": "OKP",
|
||||
"crv": "Ed25519",
|
||||
"x": "b2naX3qFwv_RDOHZMPsYSii82JicgY3srqJSZfranzE",
|
||||
"use": "sig",
|
||||
});
|
||||
(200, json!({"keys": [key]}).to_string())
|
||||
})
|
||||
.await;
|
||||
let directory = test_directory_at(&base);
|
||||
directory.cache.write().await.last_updated = Instant::now() - Duration::from_secs(600);
|
||||
|
||||
let rotated = |claims: serde_json::Value| {
|
||||
let mut claims = claims;
|
||||
claims["iss"] = json!("https://idp.example.org");
|
||||
claims["exp"] = json!(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
+ 300
|
||||
);
|
||||
claims["email"] = json!("[email protected]");
|
||||
jsonwebtoken::encode(
|
||||
&jsonwebtoken::Header::new(Algorithm::EdDSA),
|
||||
&claims,
|
||||
&jsonwebtoken::EncodingKey::from_ed_pem(ROTATED_KEY.as_bytes()).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
sign_in(&directory, rotated(json!({})))
|
||||
.await
|
||||
.map(|account| account.email)
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some("[email protected]")
|
||||
);
|
||||
assert_eq!(fetches.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||||
|
||||
// A forged one right after doesn't fetch again
|
||||
let mut forged = rotated(json!({}));
|
||||
forged.replace_range(forged.len() - 4.., "AAAA");
|
||||
assert!(sign_in(&directory, forged).await.is_err());
|
||||
assert_eq!(fetches.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_string_list_distinguishes_absent_from_empty() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use ahash::AHashMap;
|
||||
@@ -22,6 +24,26 @@ pub struct OidcConfig {
|
||||
pub claim_name: Option<String>,
|
||||
pub claim_groups: Option<String>,
|
||||
pub default_domain: Option<String>,
|
||||
pub opaque_tokens: OpaqueTokens,
|
||||
}
|
||||
|
||||
/// inbuxa: http-directory-services-13: what a bearer token that isn't a
|
||||
/// JWT signs in by.
|
||||
pub enum OpaqueTokens {
|
||||
/// Nothing: refused.
|
||||
Reject,
|
||||
/// The provider's user info endpoint answering for it, which it does for
|
||||
/// any token it issued, to any application.
|
||||
Userinfo,
|
||||
/// The provider's token introspection endpoint (RFC 7662).
|
||||
Introspection(Introspection),
|
||||
}
|
||||
|
||||
pub struct Introspection {
|
||||
/// None until discovery: then the discovery document's, if not set.
|
||||
pub url: Option<String>,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
pub struct OidcDiscovery {
|
||||
@@ -38,6 +60,9 @@ pub struct DiscoveryDocument {
|
||||
pub authorization_endpoint: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub end_session_endpoint: Option<String>,
|
||||
// inbuxa: read for opaque tokens, not passed on to clients
|
||||
#[serde(default, skip_serializing)]
|
||||
pub introspection_endpoint: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scopes_supported: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -15,7 +15,8 @@ use calcard::{
|
||||
common::timezone::Tz,
|
||||
icalendar::{
|
||||
ICalendar, ICalendarComponentType, ICalendarFrequency, ICalendarRecurrenceRule,
|
||||
ICalendarValue, dates::TimeOrDelta,
|
||||
ICalendarValue,
|
||||
dates::{CalendarError, CalendarErrorType, TimeOrDelta},
|
||||
},
|
||||
};
|
||||
use compact_str::ToCompactString;
|
||||
@@ -27,6 +28,21 @@ use store::{
|
||||
|
||||
const MAX_TIME_SPAN: i64 = u32::MAX as i64;
|
||||
|
||||
// inbuxa: the object to log with a rule expansion error. Not when the work
|
||||
// budget ran out: anyone who can get an object stored (a sharee, or a sender
|
||||
// whose invitation is processed) can cause that on purpose, and every time it
|
||||
// would copy up to the whole object into the log
|
||||
fn expansion_error_details(ical: &ICalendar, errors: &[CalendarError]) -> Option<String> {
|
||||
if errors
|
||||
.iter()
|
||||
.any(|err| err.error == CalendarErrorType::ExpansionBudgetExhausted)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(ical.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// calcard builds every candidate of one period (days x BYHOUR x BYMINUTE x
|
||||
// BYSECOND) before the expansion limit applies, so a rule is refused when that
|
||||
// set is larger than this.
|
||||
@@ -233,6 +249,7 @@ impl CalendarEventData {
|
||||
}
|
||||
|
||||
if !expanded.errors.is_empty() {
|
||||
let details = expansion_error_details(&ical, &expanded.errors);
|
||||
trc::event!(
|
||||
Calendar(trc::CalendarEvent::RuleExpansionError),
|
||||
Reason = expanded
|
||||
@@ -240,7 +257,7 @@ impl CalendarEventData {
|
||||
.into_iter()
|
||||
.map(|e| e.error.to_compact_string())
|
||||
.collect::<Vec<_>>(),
|
||||
Details = ical.to_string(),
|
||||
Details = details,
|
||||
Limit = max_expansions,
|
||||
);
|
||||
}
|
||||
@@ -355,3 +372,55 @@ impl ArchivedTimezone {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use calcard::{
|
||||
common::timezone::Tz,
|
||||
icalendar::{ICalendar, dates::CalendarErrorType},
|
||||
};
|
||||
|
||||
// The shared expansion budget is carried by vendor/calcard, so this stops
|
||||
// building if Cargo.lock moves calcard past the vendored copy
|
||||
#[test]
|
||||
fn rule_expansion_shares_one_budget() {
|
||||
let mut ical = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n".to_string();
|
||||
for _ in 0..40 {
|
||||
ical.push_str(
|
||||
"BEGIN:VEVENT\r\nUID:budget\r\nDTSTART:20000101T000000Z\r\n\
|
||||
RRULE:FREQ=DAILY;BYMONTH=2;BYMONTHDAY=31\r\nEND:VEVENT\r\n",
|
||||
);
|
||||
}
|
||||
ical.push_str("END:VCALENDAR\r\n");
|
||||
|
||||
let parsed = ICalendar::parse(&ical).unwrap();
|
||||
let expanded = parsed.expand_dates(Tz::UTC, 3000);
|
||||
assert!(expanded.events.is_empty());
|
||||
assert!(
|
||||
expanded
|
||||
.errors
|
||||
.iter()
|
||||
.any(|err| err.error == CalendarErrorType::ExpansionBudgetExhausted)
|
||||
);
|
||||
|
||||
// Anyone who can get an object stored can use up the budget, so its
|
||||
// log entry leaves the object out
|
||||
assert_eq!(
|
||||
super::expansion_error_details(&parsed, &expanded.errors),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_expansion_errors_keep_the_object() {
|
||||
let ical = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:x\r\n\
|
||||
RRULE:FREQ=DAILY\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
let parsed = ICalendar::parse(ical).unwrap();
|
||||
let expanded = parsed.expand_dates(Tz::UTC, 3000);
|
||||
assert!(!expanded.errors.is_empty());
|
||||
assert!(
|
||||
super::expansion_error_details(&parsed, &expanded.errors)
|
||||
.is_some_and(|details| details.contains("UID:x"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::{ImapResponse, authenticate::Mechanism};
|
||||
@@ -144,6 +146,7 @@ impl Capability {
|
||||
is_authenticated: bool,
|
||||
offer_tls: bool,
|
||||
allow_auth: bool,
|
||||
allow_bearer: bool,
|
||||
message_limit: u32,
|
||||
save_limit: u32,
|
||||
) -> Vec<Capability> {
|
||||
@@ -195,11 +198,14 @@ impl Capability {
|
||||
Capability::SaveLimit(save_limit),
|
||||
]);
|
||||
} else if allow_auth {
|
||||
capabilities.extend([
|
||||
Capability::Auth(Mechanism::Plain),
|
||||
Capability::Auth(Mechanism::OAuthBearer),
|
||||
Capability::Auth(Mechanism::XOauth2),
|
||||
]);
|
||||
capabilities.push(Capability::Auth(Mechanism::Plain));
|
||||
// inbuxa: bearer tokens are only offered over TLS
|
||||
if allow_bearer {
|
||||
capabilities.extend([
|
||||
Capability::Auth(Mechanism::OAuthBearer),
|
||||
Capability::Auth(Mechanism::XOauth2),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
capabilities.push(Capability::LoginDisabled);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use imap_proto::{
|
||||
};
|
||||
use trc::SecurityEvent;
|
||||
|
||||
use super::{SelectedMailbox, Session, SessionData, State};
|
||||
use super::{SelectedMailbox, Session, SessionData, State, gate::OpKind};
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn ingest(&mut self, bytes: &[u8]) -> SessionResult {
|
||||
@@ -113,6 +113,14 @@ impl<T: SessionStream> Session<T> {
|
||||
.collect::<Vec<_>>(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
// inbuxa: a command handled here rather than in a spawned task
|
||||
// waits for every operation in flight and holds the whole gate
|
||||
// while it runs; the others take their permit in their handler
|
||||
let inline_permit = if takes_own_permit(&request.command) {
|
||||
None
|
||||
} else {
|
||||
Some(self.op_gate.acquire(OpKind::Write).await)
|
||||
};
|
||||
let dispatch = async {
|
||||
match request.command {
|
||||
Command::List | Command::Lsub => self
|
||||
@@ -285,6 +293,8 @@ impl<T: SessionStream> Session<T> {
|
||||
store::backend::scaleout::replica::replica_read(replica_accounts, dispatch).await
|
||||
};
|
||||
|
||||
drop(inline_permit);
|
||||
|
||||
match result {
|
||||
Ok(SessionResult::Continue) => (),
|
||||
Ok(result) => return result,
|
||||
@@ -309,6 +319,36 @@ impl<T: SessionStream> Session<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: commands whose handler takes a read or write permit itself and
|
||||
// runs the operation in a spawned task
|
||||
fn takes_own_permit(command: &Command) -> bool {
|
||||
matches!(
|
||||
command,
|
||||
Command::List
|
||||
| Command::Lsub
|
||||
| Command::Create
|
||||
| Command::Delete
|
||||
| Command::Rename
|
||||
| Command::Status
|
||||
| Command::Append
|
||||
| Command::Search(_)
|
||||
| Command::Sort(_)
|
||||
| Command::Fetch(_)
|
||||
| Command::Store(_)
|
||||
| Command::Copy(_)
|
||||
| Command::Move(_)
|
||||
| Command::Thread(_)
|
||||
| Command::Subscribe
|
||||
| Command::Unsubscribe
|
||||
| Command::SetAcl
|
||||
| Command::DeleteAcl
|
||||
| Command::GetAcl
|
||||
| Command::MyRights
|
||||
| Command::GetQuota
|
||||
| Command::GetQuotaRoot
|
||||
)
|
||||
}
|
||||
|
||||
pub fn group_requests(
|
||||
requests: &mut Peekable<IntoIter<Request<Command>>>,
|
||||
mut grouped_requests: Vec<Request<Command>>,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! inbuxa: bounds how many operations one IMAP session runs at once and
|
||||
//! keeps commands that change state in order (RFC 9051, section 5.5). A
|
||||
//! read takes one permit and a write takes all of them, so a write starts
|
||||
//! once every earlier operation has finished and later ones wait for it.
|
||||
//! The session loop waits for its permit before it starts the operation
|
||||
//! and reads no more commands meanwhile.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
||||
|
||||
/// Operations one session may run at the same time.
|
||||
pub const MAX_CONCURRENT_OPS: u32 = 4;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OpKind {
|
||||
/// Leaves the account and the session as they were
|
||||
Read,
|
||||
/// Changes the account or the session, so runs on its own
|
||||
Write,
|
||||
}
|
||||
|
||||
pub type OpPermit = OwnedSemaphorePermit;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OpGate(Arc<Semaphore>);
|
||||
|
||||
impl OpGate {
|
||||
pub fn new() -> Self {
|
||||
OpGate(Arc::new(Semaphore::new(MAX_CONCURRENT_OPS as usize)))
|
||||
}
|
||||
|
||||
/// Waits until an operation of this kind may start. Permits are handed
|
||||
/// out in the order they were asked for.
|
||||
pub async fn acquire(&self, kind: OpKind) -> OpPermit {
|
||||
let permits = match kind {
|
||||
OpKind::Read => 1,
|
||||
OpKind::Write => MAX_CONCURRENT_OPS,
|
||||
};
|
||||
self.0
|
||||
.clone()
|
||||
.acquire_many_owned(permits)
|
||||
.await
|
||||
.expect("the operation gate is never closed")
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OpGate {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MAX_CONCURRENT_OPS, OpGate, OpKind};
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
// A permit that can't be had now: nothing else here frees one, so the
|
||||
// timeout always expires
|
||||
async fn is_blocked(gate: &OpGate, kind: OpKind) -> bool {
|
||||
tokio::time::timeout(Duration::from_millis(50), gate.acquire(kind))
|
||||
.await
|
||||
.is_err()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_are_bounded() {
|
||||
let gate = OpGate::new();
|
||||
let mut held = Vec::new();
|
||||
for _ in 0..MAX_CONCURRENT_OPS {
|
||||
held.push(gate.acquire(OpKind::Read).await);
|
||||
}
|
||||
assert!(is_blocked(&gate, OpKind::Read).await);
|
||||
held.pop();
|
||||
held.push(gate.acquire(OpKind::Read).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_waits_for_reads_and_reads_wait_for_write() {
|
||||
let gate = OpGate::new();
|
||||
let read = gate.acquire(OpKind::Read).await;
|
||||
assert!(is_blocked(&gate, OpKind::Write).await);
|
||||
drop(read);
|
||||
|
||||
let write = gate.acquire(OpKind::Write).await;
|
||||
assert!(is_blocked(&gate, OpKind::Read).await);
|
||||
assert!(is_blocked(&gate, OpKind::Write).await);
|
||||
drop(write);
|
||||
gate.acquire(OpKind::Read).await;
|
||||
}
|
||||
|
||||
// Drives the gate the way the session loop does: permits are taken in
|
||||
// command order, then each operation runs in its own task
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn pipelined_operations_keep_order() {
|
||||
use OpKind::{Read, Write};
|
||||
let commands = [
|
||||
Read, Read, Read, Read, Read, Read, Write, Read, Read, Write, Write, Read, Read, Read,
|
||||
Read, Read, Read, Read, Write, Read,
|
||||
];
|
||||
let gate = OpGate::new();
|
||||
let running = Arc::new(AtomicUsize::new(0));
|
||||
let most_running = Arc::new(AtomicUsize::new(0));
|
||||
let log = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for (id, kind) in commands.into_iter().enumerate() {
|
||||
let permit = gate.acquire(kind).await;
|
||||
let running = running.clone();
|
||||
let most_running = most_running.clone();
|
||||
let log = log.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let now = running.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
most_running.fetch_max(now, Ordering::SeqCst);
|
||||
log.lock().push((id, true));
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
log.lock().push((id, false));
|
||||
running.fetch_sub(1, Ordering::SeqCst);
|
||||
drop(permit);
|
||||
}));
|
||||
}
|
||||
for task in tasks {
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
assert!(most_running.load(Ordering::SeqCst) <= MAX_CONCURRENT_OPS as usize);
|
||||
let log = log.lock();
|
||||
let position = |id: usize, start: bool| log.iter().position(|e| *e == (id, start)).unwrap();
|
||||
for (write, kind) in commands.iter().enumerate() {
|
||||
if *kind != Write {
|
||||
continue;
|
||||
}
|
||||
for other in 0..commands.len() {
|
||||
if other < write {
|
||||
assert!(
|
||||
position(other, false) < position(write, true),
|
||||
"write {write} started before {other} finished"
|
||||
);
|
||||
} else if other > write {
|
||||
assert!(
|
||||
position(write, false) < position(other, true),
|
||||
"{other} started before write {write} finished"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ use tokio::{
|
||||
use trc::AddContext;
|
||||
|
||||
pub mod client;
|
||||
pub mod gate;
|
||||
pub mod mailbox;
|
||||
pub mod message;
|
||||
pub mod session;
|
||||
@@ -61,6 +62,8 @@ pub struct Session<T: SessionStream> {
|
||||
pub in_flight: InFlight,
|
||||
pub remote_addr: IpAddr,
|
||||
pub session_id: u64,
|
||||
// inbuxa: bounds and orders the operations this session runs
|
||||
pub op_gate: gate::OpGate,
|
||||
}
|
||||
|
||||
pub struct SessionData<T: SessionStream> {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use super::{ImapSessionManager, Session, State};
|
||||
use crate::{
|
||||
GREETING_WITH_TLS, GREETING_WITH_TLS_LOGIN_DISABLED, GREETING_WITHOUT_TLS,
|
||||
GREETING_CLEAR_TEXT, GREETING_WITH_TLS, GREETING_WITH_TLS_LOGIN_DISABLED, GREETING_WITHOUT_TLS,
|
||||
GREETING_WITHOUT_TLS_LOGIN_DISABLED,
|
||||
};
|
||||
use common::{
|
||||
@@ -129,7 +129,8 @@ impl<T: SessionStream> Session<T> {
|
||||
let greeting = match (offer_tls, allow_auth) {
|
||||
(true, true) => &GREETING_WITH_TLS,
|
||||
(true, false) => &GREETING_WITH_TLS_LOGIN_DISABLED,
|
||||
(false, true) => &GREETING_WITHOUT_TLS,
|
||||
(false, true) if is_tls => &GREETING_WITHOUT_TLS,
|
||||
(false, true) => &GREETING_CLEAR_TEXT,
|
||||
(false, false) => &GREETING_WITHOUT_TLS_LOGIN_DISABLED,
|
||||
};
|
||||
|
||||
@@ -169,6 +170,7 @@ impl<T: SessionStream> Session<T> {
|
||||
session_id: session.session_id,
|
||||
in_flight: session.in_flight,
|
||||
remote_addr: session.remote_ip,
|
||||
op_gate: super::gate::OpGate::new(),
|
||||
stream_rx,
|
||||
stream_tx: Arc::new(tokio::sync::Mutex::new(stream_tx)),
|
||||
})
|
||||
@@ -236,6 +238,7 @@ impl<T: SessionStream> Session<T> {
|
||||
session_id: self.session_id,
|
||||
in_flight: self.in_flight,
|
||||
remote_addr: self.remote_addr,
|
||||
op_gate: self.op_gate,
|
||||
stream_rx,
|
||||
stream_tx,
|
||||
})
|
||||
|
||||
+17
-6
@@ -21,21 +21,32 @@ pub mod op;
|
||||
static SERVER_GREETING: &str = concat!(types::brand!(), " IMAP4rev2 at your service.");
|
||||
|
||||
pub(crate) static GREETING_WITH_TLS: LazyLock<Vec<u8>> =
|
||||
LazyLock::new(|| build_greeting(true, true));
|
||||
LazyLock::new(|| build_greeting(true, true, false));
|
||||
|
||||
pub(crate) static GREETING_WITH_TLS_LOGIN_DISABLED: LazyLock<Vec<u8>> =
|
||||
LazyLock::new(|| build_greeting(true, false));
|
||||
LazyLock::new(|| build_greeting(true, false, false));
|
||||
|
||||
pub(crate) static GREETING_WITHOUT_TLS: LazyLock<Vec<u8>> =
|
||||
LazyLock::new(|| build_greeting(false, true));
|
||||
LazyLock::new(|| build_greeting(false, true, true));
|
||||
|
||||
// inbuxa: clear text with no STARTTLS to offer, so no bearer tokens either
|
||||
pub(crate) static GREETING_CLEAR_TEXT: LazyLock<Vec<u8>> =
|
||||
LazyLock::new(|| build_greeting(false, true, false));
|
||||
|
||||
pub(crate) static GREETING_WITHOUT_TLS_LOGIN_DISABLED: LazyLock<Vec<u8>> =
|
||||
LazyLock::new(|| build_greeting(false, false));
|
||||
LazyLock::new(|| build_greeting(false, false, false));
|
||||
|
||||
fn build_greeting(offer_tls: bool, allow_auth: bool) -> Vec<u8> {
|
||||
fn build_greeting(offer_tls: bool, allow_auth: bool, allow_bearer: bool) -> Vec<u8> {
|
||||
StatusResponse::ok(SERVER_GREETING)
|
||||
.with_code(ResponseCode::Capability {
|
||||
capabilities: Capability::all_capabilities(false, offer_tls, allow_auth, 0, 0),
|
||||
capabilities: Capability::all_capabilities(
|
||||
false,
|
||||
offer_tls,
|
||||
allow_auth,
|
||||
allow_bearer,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
})
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
core::{MailboxId, Session, SessionData, State},
|
||||
core::{MailboxId, Session, SessionData, State, gate::OpKind},
|
||||
op::ImapContext,
|
||||
spawn_op,
|
||||
};
|
||||
@@ -40,13 +40,14 @@ impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_get_acl(&mut self, request: Request<Command>) -> trc::Result<()> {
|
||||
// Validate access
|
||||
self.assert_has_permission(Permission::ImapAclGet)?;
|
||||
let permit = self.op_gate.acquire(OpKind::Read).await;
|
||||
|
||||
let op_start = Instant::now();
|
||||
let arguments = request.parse_acl(self.is_utf8)?;
|
||||
let is_utf8 = self.version.is_rev2() || self.is_utf8;
|
||||
let data = self.state.session_data();
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
let (mailbox_id, mailbox_, _) = data
|
||||
.get_acl_mailbox(&arguments, true)
|
||||
.await
|
||||
@@ -169,13 +170,14 @@ impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_my_rights(&mut self, request: Request<Command>) -> trc::Result<()> {
|
||||
// Validate access
|
||||
self.assert_has_permission(Permission::ImapMyRights)?;
|
||||
let permit = self.op_gate.acquire(OpKind::Read).await;
|
||||
|
||||
let op_start = Instant::now();
|
||||
let arguments = request.parse_acl(self.is_utf8)?;
|
||||
let data = self.state.session_data();
|
||||
let is_utf8 = self.version.is_rev2() || self.is_utf8;
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
let (mailbox_id, mailbox_, access_token) = data
|
||||
.get_acl_mailbox(&arguments, false)
|
||||
.await
|
||||
@@ -262,13 +264,14 @@ impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_set_acl(&mut self, request: Request<Command>) -> trc::Result<()> {
|
||||
// Validate access
|
||||
self.assert_has_permission(Permission::ImapAclSet)?;
|
||||
let permit = self.op_gate.acquire(OpKind::Write).await;
|
||||
|
||||
let op_start = Instant::now();
|
||||
let command = request.command;
|
||||
let arguments = request.parse_acl(self.is_utf8)?;
|
||||
let data = self.state.session_data();
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
// Validate mailbox
|
||||
let (mailbox_id, current_mailbox, access_token) = data
|
||||
.get_acl_mailbox(&arguments, true)
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::{ImapContext, ToModSeq};
|
||||
use crate::{
|
||||
core::{ImapUidToId, MailboxId, SelectedMailbox, Session, SessionData},
|
||||
core::{ImapUidToId, MailboxId, SelectedMailbox, Session, SessionData, gate::OpKind},
|
||||
spawn_op,
|
||||
};
|
||||
use common::{auth::BuildAccessToken, ipc::PushNotification, network::SessionStream};
|
||||
@@ -29,6 +31,7 @@ impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_append(&mut self, request: Request<Command>) -> trc::Result<()> {
|
||||
// Validate access
|
||||
self.assert_has_permission(Permission::ImapAppend)?;
|
||||
let permit = self.op_gate.acquire(OpKind::Write).await;
|
||||
|
||||
let op_start = Instant::now();
|
||||
let arguments = request.parse_append(self.is_utf8)?;
|
||||
@@ -67,7 +70,7 @@ impl<T: SessionStream> Session<T> {
|
||||
};
|
||||
let is_qresync = self.is_qresync;
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
let response = data
|
||||
.append_messages(arguments, selected_mailbox, mailbox, is_qresync, op_start)
|
||||
.await?
|
||||
|
||||
@@ -25,6 +25,16 @@ impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_authenticate(&mut self, request: Request<Command>) -> trc::Result<()> {
|
||||
let mut args = request.parse_authenticate()?;
|
||||
|
||||
// inbuxa: a bearer token is a credential on its own, so it is never
|
||||
// asked for or taken on clear text, whatever the plain-text setting
|
||||
if !self.is_tls && matches!(args.mechanism, Mechanism::OAuthBearer | Mechanism::XOauth2) {
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Bearer tokens require TLS.")
|
||||
.id(args.tag)
|
||||
.code(ResponseCode::PrivacyRequired));
|
||||
}
|
||||
|
||||
match args.mechanism {
|
||||
Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => {
|
||||
if !args.params.is_empty() {
|
||||
@@ -138,6 +148,7 @@ impl<T: SessionStream> Session<T> {
|
||||
true,
|
||||
!self.is_tls && self.instance.acceptor.is_tls(),
|
||||
true,
|
||||
self.is_tls,
|
||||
self.server.core.imap.max_messages_per_command,
|
||||
self.server.core.imap.max_messages_per_save,
|
||||
),
|
||||
|
||||
@@ -44,6 +44,7 @@ impl<T: SessionStream> Session<T> {
|
||||
self.state.is_authenticated(),
|
||||
!self.is_tls && self.instance.acceptor.is_tls(),
|
||||
self.is_tls || self.server.core.imap.allow_plain_auth,
|
||||
self.is_tls,
|
||||
self.server.core.imap.max_messages_per_command,
|
||||
self.server.core.imap.max_messages_per_save,
|
||||
),
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::ImapContext;
|
||||
use crate::{
|
||||
core::{MailboxId, SelectedMailbox, Session, SessionData},
|
||||
core::{MailboxId, SelectedMailbox, Session, SessionData, gate::OpKind},
|
||||
spawn_op,
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
@@ -58,6 +60,7 @@ impl<T: SessionStream> Session<T> {
|
||||
} else {
|
||||
Permission::ImapCopy
|
||||
})?;
|
||||
let permit = self.op_gate.acquire(OpKind::Write).await;
|
||||
|
||||
let op_start = Instant::now();
|
||||
let arguments = request.parse_copy_move(self.is_utf8)?;
|
||||
@@ -70,7 +73,7 @@ impl<T: SessionStream> Session<T> {
|
||||
self.server.core.imap.max_messages_per_save
|
||||
};
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
// Refresh mailboxes
|
||||
data.synchronize_mailboxes(false)
|
||||
.await
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
core::{Session, SessionData},
|
||||
core::{Session, SessionData, gate::OpKind},
|
||||
op::ImapContext,
|
||||
spawn_op,
|
||||
};
|
||||
@@ -28,12 +28,13 @@ impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_create(&mut self, requests: Vec<Request<Command>>) -> trc::Result<()> {
|
||||
// Validate access
|
||||
self.assert_has_permission(Permission::ImapCreate)?;
|
||||
let permit = self.op_gate.acquire(OpKind::Write).await;
|
||||
|
||||
let data = self.state.session_data();
|
||||
let is_utf8 = self.is_utf8;
|
||||
let is_objectid = self.is_objectid;
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
for request in requests {
|
||||
match request.parse_create(is_utf8) {
|
||||
Ok(argument) => match data.create_folder(argument, is_objectid).await {
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::ImapContext;
|
||||
use crate::{
|
||||
core::{Session, SessionData},
|
||||
core::{Session, SessionData, gate::OpKind},
|
||||
spawn_op,
|
||||
};
|
||||
use common::network::SessionStream;
|
||||
@@ -21,11 +23,12 @@ impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_delete(&mut self, requests: Vec<Request<Command>>) -> trc::Result<()> {
|
||||
// Validate access
|
||||
self.assert_has_permission(Permission::ImapDelete)?;
|
||||
let permit = self.op_gate.acquire(OpKind::Write).await;
|
||||
|
||||
let data = self.state.session_data();
|
||||
let is_utf8 = self.is_utf8;
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
for request in requests {
|
||||
match request.parse_delete(is_utf8) {
|
||||
Ok(argument) => match data.delete_folder(argument).await {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use super::{FromModSeq, ImapContext};
|
||||
use crate::{
|
||||
core::{SelectedMailbox, Session, SessionData},
|
||||
core::{SelectedMailbox, Session, SessionData, gate::OpKind},
|
||||
spawn_op,
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
@@ -94,11 +94,23 @@ impl<T: SessionStream> Session<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: a FETCH that marks messages \Seen changes the mailbox
|
||||
let kind = if mailbox.is_select
|
||||
&& ops.iter().any(|op| {
|
||||
op.as_ref()
|
||||
.is_ok_and(|(_, _, arguments)| may_set_seen(&arguments.attributes))
|
||||
}) {
|
||||
OpKind::Write
|
||||
} else {
|
||||
OpKind::Read
|
||||
};
|
||||
let permit = self.op_gate.acquire(kind).await;
|
||||
|
||||
if activate_objectid && let Some(enabled) = self.activate_objectid() {
|
||||
self.write_bytes(enabled).await?;
|
||||
}
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
for op in ops {
|
||||
match op {
|
||||
Ok((is_uid, enabled_condstore, arguments)) => {
|
||||
@@ -131,6 +143,19 @@ impl<T: SessionStream> Session<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: errs on the side of a write; a header-only section counts too
|
||||
fn may_set_seen(attributes: &[Attribute]) -> bool {
|
||||
attributes.iter().any(|attribute| {
|
||||
matches!(
|
||||
attribute,
|
||||
Attribute::BodySection { peek: false, .. }
|
||||
| Attribute::Binary { peek: false, .. }
|
||||
| Attribute::Rfc822
|
||||
| Attribute::Rfc822Text
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
impl<T: SessionStream> SessionData<T> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn fetch(
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::{
|
||||
core::{Session, SessionData},
|
||||
core::{Session, SessionData, gate::OpKind},
|
||||
spawn_op,
|
||||
};
|
||||
use common::network::SessionStream;
|
||||
@@ -48,6 +48,7 @@ impl<T: SessionStream> Session<T> {
|
||||
request.parse_lsub(self.is_utf8)
|
||||
}?;
|
||||
|
||||
let permit = self.op_gate.acquire(OpKind::Read).await;
|
||||
// Activate OBJECTID+ when the OBJECTID return option or STATUS attribute is requested
|
||||
if let Arguments::Extended { return_options, .. } = &arguments
|
||||
&& return_options.iter().any(|option| match option {
|
||||
@@ -67,6 +68,7 @@ impl<T: SessionStream> Session<T> {
|
||||
|
||||
spawn_op!(
|
||||
data,
|
||||
permit,
|
||||
data.list(arguments, is_lsub, version, is_utf8, op_start)
|
||||
.await
|
||||
)
|
||||
|
||||
@@ -62,10 +62,14 @@ impl ToModSeq for u64 {
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! spawn_op {
|
||||
($data:expr, $($code:tt)*) => {
|
||||
($data:expr, $permit:expr, $($code:tt)*) => {
|
||||
{
|
||||
// inbuxa: the operation holds the permit the handler took from the
|
||||
// session's gate until it finishes
|
||||
let permit: $crate::core::gate::OpPermit = $permit;
|
||||
// inbuxa: ST-6: the operation keeps its read scope
|
||||
tokio::spawn(store::backend::scaleout::replica::carry(async move {
|
||||
let _permit = permit;
|
||||
let data = &($data);
|
||||
|
||||
if let Err(err) = (async {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
/*
|
||||
@@ -11,7 +13,7 @@
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
core::{Session, SessionData},
|
||||
core::{Session, SessionData, gate::OpKind},
|
||||
op::ImapContext,
|
||||
spawn_op,
|
||||
};
|
||||
@@ -32,10 +34,11 @@ impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_get_quota(&mut self, request: Request<Command>) -> trc::Result<()> {
|
||||
// Validate access
|
||||
self.assert_has_permission(Permission::ImapStatus)?;
|
||||
let permit = self.op_gate.acquire(OpKind::Read).await;
|
||||
|
||||
let data = self.state.session_data();
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
match request.parse_get_quota() {
|
||||
Ok(argument) => match data.get_quota(argument).await {
|
||||
Ok(response) => {
|
||||
@@ -55,11 +58,12 @@ impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_get_quota_root(&mut self, request: Request<Command>) -> trc::Result<()> {
|
||||
// Validate access
|
||||
self.assert_has_permission(Permission::ImapStatus)?;
|
||||
let permit = self.op_gate.acquire(OpKind::Read).await;
|
||||
|
||||
let data = self.state.session_data();
|
||||
let is_utf8 = self.is_utf8;
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
match request.parse_get_quota_root(is_utf8) {
|
||||
Ok(argument) => match data.get_quota_root(argument).await {
|
||||
Ok(response) => {
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
core::{Session, SessionData},
|
||||
core::{Session, SessionData, gate::OpKind},
|
||||
spawn_op,
|
||||
};
|
||||
use common::{network::SessionStream, sharing::EffectiveAcl, storage::index::ObjectIndexBuilder};
|
||||
@@ -30,13 +32,14 @@ impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_rename(&mut self, request: Request<Command>) -> trc::Result<()> {
|
||||
// Validate access
|
||||
self.assert_has_permission(Permission::ImapRename)?;
|
||||
let permit = self.op_gate.acquire(OpKind::Write).await;
|
||||
|
||||
let op_start = Instant::now();
|
||||
let arguments = request.parse_rename(self.is_utf8)?;
|
||||
let data = self.state.session_data();
|
||||
let is_objectid = self.is_objectid;
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
let response = data.rename_folder(arguments, is_objectid, op_start).await?;
|
||||
data.write_bytes(response.into_bytes()).await
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use super::{FromModSeq, ToModSeq};
|
||||
use crate::{
|
||||
core::{ImapId, SavedSearch, SelectedMailbox, Session, SessionData},
|
||||
core::{ImapId, SavedSearch, SelectedMailbox, Session, SessionData, gate::OpKind},
|
||||
spawn_op,
|
||||
};
|
||||
use common::network::SessionStream;
|
||||
@@ -58,6 +58,7 @@ impl<T: SessionStream> Session<T> {
|
||||
request.parse_sort()
|
||||
}?;
|
||||
|
||||
let permit = self.op_gate.acquire(OpKind::Read).await;
|
||||
// RFC 9586 forbids the sequence set criterion once UIDONLY is enabled
|
||||
if self.is_uidonly
|
||||
&& arguments.filter.iter().any(|filter| {
|
||||
@@ -86,7 +87,7 @@ impl<T: SessionStream> Session<T> {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
let tag = std::mem::take(&mut arguments.tag);
|
||||
let bytes = match data
|
||||
.search(
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::ToModSeq;
|
||||
use crate::{
|
||||
core::{Mailbox, Session, SessionData},
|
||||
core::{Mailbox, Session, SessionData, gate::OpKind},
|
||||
op::ImapContext,
|
||||
spawn_op,
|
||||
};
|
||||
@@ -30,6 +32,7 @@ impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_status(&mut self, requests: Vec<Request<Command>>) -> trc::Result<()> {
|
||||
// Validate access
|
||||
self.assert_has_permission(Permission::ImapStatus)?;
|
||||
let permit = self.op_gate.acquire(OpKind::Read).await;
|
||||
|
||||
let is_utf8 = self.is_utf8;
|
||||
|
||||
@@ -53,7 +56,7 @@ impl<T: SessionStream> Session<T> {
|
||||
|
||||
let data = self.state.session_data();
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
let mut did_sync = false;
|
||||
|
||||
for request in parsed {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use super::{FromModSeq, ImapContext};
|
||||
use crate::{
|
||||
core::{SelectedMailbox, Session, SessionData},
|
||||
core::{SelectedMailbox, Session, SessionData, gate::OpKind},
|
||||
spawn_op,
|
||||
};
|
||||
use ahash::AHashSet;
|
||||
@@ -51,6 +51,7 @@ impl<T: SessionStream> Session<T> {
|
||||
) -> trc::Result<()> {
|
||||
// Validate access
|
||||
self.assert_has_permission(Permission::ImapStore)?;
|
||||
let permit = self.op_gate.acquire(OpKind::Write).await;
|
||||
|
||||
let op_start = Instant::now();
|
||||
let arguments = request.parse_store()?;
|
||||
@@ -61,7 +62,7 @@ impl<T: SessionStream> Session<T> {
|
||||
let message_limit = self.server.core.imap.max_messages_per_command;
|
||||
|
||||
if spawn {
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
let response = data
|
||||
.store(
|
||||
arguments,
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::ImapContext;
|
||||
use crate::{
|
||||
core::{Session, SessionData},
|
||||
core::{Session, SessionData, gate::OpKind},
|
||||
spawn_op,
|
||||
};
|
||||
use common::{network::SessionStream, storage::index::ObjectIndexBuilder};
|
||||
@@ -27,12 +29,13 @@ impl<T: SessionStream> Session<T> {
|
||||
) -> trc::Result<()> {
|
||||
// Validate access
|
||||
self.assert_has_permission(Permission::ImapSubscribe)?;
|
||||
let permit = self.op_gate.acquire(OpKind::Write).await;
|
||||
|
||||
let op_start = Instant::now();
|
||||
let arguments = request.parse_subscribe(self.is_utf8)?;
|
||||
let data = self.state.session_data();
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
let response = data
|
||||
.subscribe_folder(
|
||||
arguments.tag,
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
core::{SelectedMailbox, Session, SessionData},
|
||||
core::{SelectedMailbox, Session, SessionData, gate::OpKind},
|
||||
spawn_op,
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
@@ -31,13 +33,14 @@ impl<T: SessionStream> Session<T> {
|
||||
) -> trc::Result<()> {
|
||||
// Validate access
|
||||
self.assert_has_permission(Permission::ImapThread)?;
|
||||
let permit = self.op_gate.acquire(OpKind::Read).await;
|
||||
|
||||
let op_start = Instant::now();
|
||||
let command = request.command;
|
||||
let mut arguments = request.parse_thread()?;
|
||||
let (data, mailbox) = self.state.mailbox_state();
|
||||
|
||||
spawn_op!(data, {
|
||||
spawn_op!(data, permit, {
|
||||
let tag = std::mem::take(&mut arguments.tag);
|
||||
|
||||
match data.thread(arguments, mailbox, is_uid, op_start).await {
|
||||
|
||||
@@ -30,6 +30,7 @@ pub mod log_settings;
|
||||
pub mod mail_rule;
|
||||
pub mod masked_email;
|
||||
pub mod masked_secrets; // inbuxa: "****" is never saved as a secret
|
||||
pub mod oidc_directory; // inbuxa: http-directory-services-13
|
||||
pub mod protocol_policy;
|
||||
pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest
|
||||
pub mod security_acceptance;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! What an external OpenID Connect directory must name before it is saved
|
||||
//! (http-directory-services-13).
|
||||
//!
|
||||
//! A directory with no required audience accepts any token its provider
|
||||
//! issued, to any application, so a new one must name the audience: one
|
||||
//! made by `x:Directory/set`, made by changing another directory's type, or
|
||||
//! made at setup. A directory saved before this rule keeps working without
|
||||
//! one and logs a warning each time it loads; it can still be edited, but an
|
||||
//! audience it has can't be removed. Requiring it of every directory is left
|
||||
//! to a later release.
|
||||
//!
|
||||
//! A directory that checks opaque tokens by introspection names the client
|
||||
//! ID and secret it calls the provider with.
|
||||
|
||||
use directory::backend::oidc::config::opaque_token_policy;
|
||||
use jmap_proto::error::set::{SetError, SetErrorType};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::OidcOpaqueTokens,
|
||||
prelude::Property,
|
||||
structs::{Directory, SecretKeyOptional},
|
||||
},
|
||||
types::error::ValidationError,
|
||||
};
|
||||
|
||||
/// `stored` is the directory as saved before this write, None for a new one.
|
||||
pub fn validate(
|
||||
directory: &Directory,
|
||||
stored: Option<&Directory>,
|
||||
) -> Result<(), SetError<Property>> {
|
||||
let Directory::Oidc(directory) = directory else {
|
||||
return Ok(());
|
||||
};
|
||||
let may_omit_audience = matches!(
|
||||
stored,
|
||||
Some(Directory::Oidc(stored)) if stored.require_audience.is_none()
|
||||
);
|
||||
if directory.require_audience.is_none() && !may_omit_audience {
|
||||
return Err(SetError::new(SetErrorType::ValidationFailed)
|
||||
.with_validation_errors(vec![ValidationError::required(Property::RequireAudience)])
|
||||
.with_description(
|
||||
"Set the required audience: the client ID or resource identifier registered \
|
||||
for this server at the provider. Without it, tokens the provider issued to any \
|
||||
other application would sign in.",
|
||||
));
|
||||
}
|
||||
|
||||
if opaque_token_policy(directory) == OidcOpaqueTokens::Introspection {
|
||||
let mut missing = Vec::new();
|
||||
if directory.introspection_client_id.is_none() {
|
||||
missing.push(ValidationError::required(Property::IntrospectionClientId));
|
||||
}
|
||||
if matches!(
|
||||
directory.introspection_client_secret,
|
||||
SecretKeyOptional::None
|
||||
) {
|
||||
missing.push(ValidationError::required(
|
||||
Property::IntrospectionClientSecret,
|
||||
));
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
return Err(SetError::new(SetErrorType::ValidationFailed)
|
||||
.with_validation_errors(missing)
|
||||
.with_description(
|
||||
"Opaque tokens are checked at the introspection endpoint, which needs the \
|
||||
client ID and secret this server signs in to the provider with.",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use registry::schema::structs::{LdapDirectory, OidcDirectory};
|
||||
|
||||
fn oidc(audience: Option<&str>) -> Directory {
|
||||
Directory::Oidc(OidcDirectory {
|
||||
require_audience: audience.map(str::to_string),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_directories_name_their_audience() {
|
||||
let ldap = Directory::Ldap(LdapDirectory::default());
|
||||
|
||||
// New, or made from a directory of another type
|
||||
assert!(validate(&oidc(None), None).is_err());
|
||||
assert!(validate(&oidc(None), Some(&ldap)).is_err());
|
||||
assert!(validate(&oidc(Some("inbuxa")), None).is_ok());
|
||||
assert!(validate(&ldap, None).is_ok());
|
||||
|
||||
// Saved before the audience was required: still editable without one
|
||||
assert!(validate(&oidc(None), Some(&oidc(None))).is_ok());
|
||||
// ... but an audience, once set, stays
|
||||
assert!(validate(&oidc(None), Some(&oidc(Some("inbuxa")))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn introspection_names_its_client() {
|
||||
let with = |policy: Option<OidcOpaqueTokens>, url: bool, client: bool| {
|
||||
Directory::Oidc(OidcDirectory {
|
||||
require_audience: Some("inbuxa".to_string()),
|
||||
opaque_tokens: policy,
|
||||
introspection_url: url.then(|| "https://idp.example.org/introspect".to_string()),
|
||||
introspection_client_id: client.then(|| "inbuxa".to_string()),
|
||||
introspection_client_secret: if client {
|
||||
SecretKeyOptional::Value(registry::schema::structs::SecretKeyValue {
|
||||
secret: "secret".to_string(),
|
||||
})
|
||||
} else {
|
||||
SecretKeyOptional::None
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
|
||||
assert!(validate(&with(None, false, false), None).is_ok());
|
||||
assert!(validate(&with(None, true, false), None).is_err());
|
||||
assert!(validate(&with(None, true, true), None).is_ok());
|
||||
assert!(
|
||||
validate(
|
||||
&with(Some(OidcOpaqueTokens::Introspection), false, false),
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate(
|
||||
&with(Some(OidcOpaqueTokens::Introspection), false, true),
|
||||
None
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(validate(&with(Some(OidcOpaqueTokens::Reject), true, false), None).is_ok());
|
||||
assert!(validate(&with(Some(OidcOpaqueTokens::Userinfo), false, false), None).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,15 @@ pub(crate) async fn bootstrap_set(
|
||||
break;
|
||||
}
|
||||
|
||||
// inbuxa: http-directory-services-13: a directory made at setup is a
|
||||
// new one, and names its audience
|
||||
if let Some(directory) = map_directory(&bootstrap.directory)
|
||||
&& let Err(err) = crate::inbuxa::oidc_directory::validate(&directory, None)
|
||||
{
|
||||
set.response.not_updated.append(id, err);
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate domain name and hostname
|
||||
let server_hostname = bootstrap
|
||||
.server_hostname
|
||||
|
||||
@@ -564,13 +564,30 @@ impl RegistrySet for Server {
|
||||
Ok(ObjectResponse::default())
|
||||
}
|
||||
}
|
||||
ObjectInner::Directory(_) if is_create => {
|
||||
validate_tenant_quota(
|
||||
self,
|
||||
access_token,
|
||||
TenantStorageQuota::MaxDirectories,
|
||||
)
|
||||
.await?
|
||||
ObjectInner::Directory(directory) => {
|
||||
// inbuxa: http-directory-services-13: a new OIDC
|
||||
// directory names its audience
|
||||
let stored = match &modification {
|
||||
Modification::Update { object, .. } => match &object.inner {
|
||||
ObjectInner::Directory(stored) => Some(stored),
|
||||
_ => None,
|
||||
},
|
||||
Modification::Create { .. } => None,
|
||||
};
|
||||
if let Err(err) =
|
||||
crate::inbuxa::oidc_directory::validate(directory, stored)
|
||||
{
|
||||
Err(err)
|
||||
} else if is_create {
|
||||
validate_tenant_quota(
|
||||
self,
|
||||
access_token,
|
||||
TenantStorageQuota::MaxDirectories,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
Ok(ObjectResponse::default())
|
||||
}
|
||||
}
|
||||
// inbuxa: legacy-protocols LP-4
|
||||
ObjectInner::NetworkListener(listener) => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::core::{Command, Session, State, StatusResponse};
|
||||
use crate::core::{Command, ResponseCode, Session, State, StatusResponse};
|
||||
use common::{
|
||||
auth::AuthRequest,
|
||||
network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
|
||||
@@ -30,6 +30,16 @@ impl<T: SessionStream> Session<T> {
|
||||
let mut tokens = request.tokens.into_iter();
|
||||
let mechanism = Mechanism::parse(&tokens.next().unwrap().unwrap_bytes())
|
||||
.map_err(|err| trc::AuthEvent::Error.into_err().details(err))?;
|
||||
|
||||
// inbuxa: a bearer token is never asked for or taken on clear text,
|
||||
// whatever the plain-text setting
|
||||
if !self.stream.is_tls() && matches!(mechanism, Mechanism::OAuthBearer | Mechanism::XOauth2)
|
||||
{
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.code(ResponseCode::EncryptNeeded)
|
||||
.details("Bearer tokens require TLS."));
|
||||
}
|
||||
let mut params: Vec<String> = tokens
|
||||
.filter_map(|token| token.unwrap_string().ok())
|
||||
.collect();
|
||||
|
||||
@@ -28,10 +28,14 @@ impl<T: SessionStream> Session<T> {
|
||||
if !self.stream.is_tls() {
|
||||
response.extend_from_slice(b"\"STARTTLS\"\r\n");
|
||||
}
|
||||
if self.stream.is_tls() || self.server.core.imap.allow_plain_auth {
|
||||
// inbuxa: bearer tokens are only offered over TLS, and nothing is
|
||||
// offered on clear text with plain-text sign-in off (STARTTLS is)
|
||||
if self.stream.is_tls() {
|
||||
response.extend_from_slice(b"\"SASL\" \"PLAIN OAUTHBEARER XOAUTH2\"\r\n");
|
||||
} else if self.server.core.imap.allow_plain_auth {
|
||||
response.extend_from_slice(b"\"SASL\" \"PLAIN\"\r\n");
|
||||
} else {
|
||||
response.extend_from_slice(b"\"SASL\" \"OAUTHBEARER XOAUTH2\"\r\n");
|
||||
response.extend_from_slice(b"\"SASL\" \"\"\r\n");
|
||||
};
|
||||
if let Some(sieve) =
|
||||
self.server
|
||||
|
||||
@@ -213,7 +213,21 @@ impl<T: SessionStream> Session<T> {
|
||||
| Command::Apop { .. } => {
|
||||
if let State::NotAuthenticated { username, .. } = &self.state {
|
||||
if self.stream.is_tls() || self.server.core.imap.allow_plain_auth {
|
||||
if !matches!(command, Command::Pass { .. }) || username.is_some() {
|
||||
if !self.stream.is_tls()
|
||||
&& matches!(
|
||||
command,
|
||||
Command::Auth {
|
||||
mechanism: Mechanism::OAuthBearer | Mechanism::XOauth2,
|
||||
..
|
||||
}
|
||||
)
|
||||
{
|
||||
// inbuxa: a bearer token is never taken on clear
|
||||
// text, whatever the plain-text setting
|
||||
Err(trc::Pop3Event::Error
|
||||
.into_err()
|
||||
.details("Bearer tokens require TLS."))
|
||||
} else if !matches!(command, Command::Pass { .. }) || username.is_some() {
|
||||
Ok(command)
|
||||
} else {
|
||||
Err(trc::Pop3Event::Error
|
||||
|
||||
@@ -20,8 +20,11 @@ pub mod list;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_capa(&mut self) -> trc::Result<()> {
|
||||
let mechanisms = if self.stream.is_tls() || self.server.core.imap.allow_plain_auth {
|
||||
let mechanisms = if self.stream.is_tls() {
|
||||
vec![Mechanism::Plain, Mechanism::OAuthBearer, Mechanism::XOauth2]
|
||||
} else if self.server.core.imap.allow_plain_auth {
|
||||
// inbuxa: bearer tokens are only offered over TLS
|
||||
vec![Mechanism::Plain]
|
||||
} else {
|
||||
// inbuxa: no mechanism is offered where none would be accepted
|
||||
vec![]
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Stored formats inbuxa changed.
|
||||
|
||||
use crate::{
|
||||
pickle::{Pickle, PickledStream},
|
||||
schema::{
|
||||
enums::OidcOpaqueTokens,
|
||||
prelude::{ObjectImpl, ObjectInner, ObjectType},
|
||||
structs::{Bootstrap, Directory, DirectoryBootstrap, OidcDirectory},
|
||||
},
|
||||
};
|
||||
|
||||
/// An OIDC directory as it was stored before the opaque-token fields.
|
||||
fn pickle_without_opaque_tokens(directory: &OidcDirectory, out: &mut Vec<u8>) {
|
||||
directory.description.pickle(out);
|
||||
directory.issuer_url.pickle(out);
|
||||
directory.require_audience.pickle(out);
|
||||
directory.require_scopes.pickle(out);
|
||||
directory.claim_username.pickle(out);
|
||||
directory.username_domain.pickle(out);
|
||||
directory.claim_name.pickle(out);
|
||||
directory.claim_groups.pickle(out);
|
||||
directory.member_tenant_id.pickle(out);
|
||||
}
|
||||
|
||||
fn read(object: ObjectType, bytes: &[u8]) -> ObjectInner {
|
||||
ObjectInner::unpickle(object, &mut PickledStream::new(bytes).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
// http-directory-services-13: a directory saved before the opaque-token
|
||||
// policy existed keeps today's behavior (userinfo); a new one starts unset
|
||||
#[test]
|
||||
fn oidc_directories_saved_before_the_opaque_token_policy_keep_userinfo() {
|
||||
let saved = OidcDirectory {
|
||||
description: "Keycloak".to_string(),
|
||||
issuer_url: "https://sso.example.org/realms/example".to_string(),
|
||||
require_audience: Some("inbuxa".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let as_read = OidcDirectory {
|
||||
opaque_tokens: Some(OidcOpaqueTokens::Userinfo),
|
||||
..saved.clone()
|
||||
};
|
||||
|
||||
// x:Directory, version 0
|
||||
let mut bytes = vec![0u8];
|
||||
2u16.pickle(&mut bytes);
|
||||
pickle_without_opaque_tokens(&saved, &mut bytes);
|
||||
assert_eq!(
|
||||
read(ObjectType::Directory, &bytes),
|
||||
ObjectInner::Directory(Directory::Oidc(as_read.clone()))
|
||||
);
|
||||
|
||||
// x:Bootstrap, version 1, where more fields follow the directory
|
||||
let bootstrap = Bootstrap {
|
||||
server_hostname: "mx.example.org".to_string(),
|
||||
directory: DirectoryBootstrap::Oidc(saved.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut bytes = vec![1u8];
|
||||
bootstrap.server_hostname.pickle(&mut bytes);
|
||||
bootstrap.default_domain.pickle(&mut bytes);
|
||||
bootstrap.request_tls_certificate.pickle(&mut bytes);
|
||||
bootstrap.generate_dkim_keys.pickle(&mut bytes);
|
||||
bootstrap.data_store.pickle(&mut bytes);
|
||||
bootstrap.blob_store.pickle(&mut bytes);
|
||||
bootstrap.search_store.pickle(&mut bytes);
|
||||
bootstrap.in_memory_store.pickle(&mut bytes);
|
||||
3u16.pickle(&mut bytes);
|
||||
pickle_without_opaque_tokens(&saved, &mut bytes);
|
||||
bootstrap.tracer.pickle(&mut bytes);
|
||||
bootstrap.dns_server.pickle(&mut bytes);
|
||||
assert_eq!(
|
||||
read(ObjectType::Bootstrap, &bytes),
|
||||
ObjectInner::Bootstrap(Bootstrap {
|
||||
directory: DirectoryBootstrap::Oidc(as_read),
|
||||
..bootstrap.clone()
|
||||
})
|
||||
);
|
||||
|
||||
// Saved now, unset or not, both read back as they were
|
||||
for directory in [
|
||||
saved.clone(),
|
||||
OidcDirectory {
|
||||
opaque_tokens: Some(OidcOpaqueTokens::Introspection),
|
||||
introspection_url: Some("https://sso.example.org/introspect".to_string()),
|
||||
introspection_client_id: Some("inbuxa".to_string()),
|
||||
..saved.clone()
|
||||
},
|
||||
] {
|
||||
let bootstrap = Bootstrap {
|
||||
directory: DirectoryBootstrap::Oidc(directory.clone()),
|
||||
..bootstrap.clone()
|
||||
};
|
||||
let directory = Directory::Oidc(directory);
|
||||
assert_eq!(
|
||||
read(ObjectType::Directory, &directory.to_pickled_vec()),
|
||||
ObjectInner::Directory(directory)
|
||||
);
|
||||
assert_eq!(
|
||||
read(ObjectType::Bootstrap, &bootstrap.to_pickled_vec()),
|
||||
ObjectInner::Bootstrap(bootstrap)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
#![warn(clippy::large_futures)]
|
||||
@@ -11,3 +13,6 @@ pub mod pickle;
|
||||
pub mod schema;
|
||||
pub mod types;
|
||||
pub mod utils;
|
||||
|
||||
#[cfg(test)]
|
||||
mod inbuxa_tests;
|
||||
@@ -1472,6 +1472,17 @@ pub enum NetworkListenerProtocol {
|
||||
ManageSieve = 5,
|
||||
}
|
||||
|
||||
// inbuxa: http-directory-services-13: what an OIDC directory does with a
|
||||
// bearer token that isn't a JWT
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
#[repr(u16)]
|
||||
pub enum OidcOpaqueTokens {
|
||||
#[default]
|
||||
Reject = 0,
|
||||
Userinfo = 1,
|
||||
Introspection = 2,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
#[repr(u16)]
|
||||
pub enum OvhEndpoint {
|
||||
|
||||
@@ -6675,6 +6675,59 @@ impl<'de> serde::Deserialize<'de> for NetworkListenerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
impl EnumImpl for OidcOpaqueTokens {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map! {
|
||||
value.as_bytes(),
|
||||
b"reject" => OidcOpaqueTokens::Reject,
|
||||
b"userinfo" => OidcOpaqueTokens::Userinfo,
|
||||
b"introspection" => OidcOpaqueTokens::Introspection,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
OidcOpaqueTokens::Reject => "reject",
|
||||
OidcOpaqueTokens::Userinfo => "userinfo",
|
||||
OidcOpaqueTokens::Introspection => "introspection",
|
||||
}
|
||||
}
|
||||
|
||||
fn to_id(&self) -> u16 {
|
||||
*self as u16
|
||||
}
|
||||
|
||||
fn from_id(id: u16) -> Option<Self> {
|
||||
match id {
|
||||
0 => Some(OidcOpaqueTokens::Reject),
|
||||
1 => Some(OidcOpaqueTokens::Userinfo),
|
||||
2 => Some(OidcOpaqueTokens::Introspection),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
const COUNT: usize = 3;
|
||||
}
|
||||
|
||||
impl serde::Serialize for OidcOpaqueTokens {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for OidcOpaqueTokens {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = Cow::<str>::deserialize(deserializer)?;
|
||||
Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[]))
|
||||
}
|
||||
}
|
||||
|
||||
impl EnumImpl for OvhEndpoint {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map! {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
// This file is auto-generated. Do not edit directly.
|
||||
@@ -982,6 +984,11 @@ pub enum Property {
|
||||
RequestTlsCertificate = 123,
|
||||
Require = 551,
|
||||
RequireAudience = 607,
|
||||
// inbuxa: http-directory-services-13
|
||||
OpaqueTokens = 935,
|
||||
IntrospectionUrl = 936,
|
||||
IntrospectionClientId = 937,
|
||||
IntrospectionClientSecret = 938,
|
||||
RequireClientRegistration = 615,
|
||||
RequireScopes = 608,
|
||||
RequireTls = 525,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
// This file is auto-generated. Do not edit directly.
|
||||
@@ -1135,6 +1137,10 @@ impl EnumImpl for Property {
|
||||
b"requestTlsCertificate" => Property::RequestTlsCertificate,
|
||||
b"require" => Property::Require,
|
||||
b"requireAudience" => Property::RequireAudience,
|
||||
b"opaqueTokens" => Property::OpaqueTokens,
|
||||
b"introspectionUrl" => Property::IntrospectionUrl,
|
||||
b"introspectionClientId" => Property::IntrospectionClientId,
|
||||
b"introspectionClientSecret" => Property::IntrospectionClientSecret,
|
||||
b"requireClientRegistration" => Property::RequireClientRegistration,
|
||||
b"requireScopes" => Property::RequireScopes,
|
||||
b"requireTls" => Property::RequireTls,
|
||||
@@ -2075,6 +2081,10 @@ impl EnumImpl for Property {
|
||||
Property::RequestTlsCertificate => "requestTlsCertificate",
|
||||
Property::Require => "require",
|
||||
Property::RequireAudience => "requireAudience",
|
||||
Property::OpaqueTokens => "opaqueTokens",
|
||||
Property::IntrospectionUrl => "introspectionUrl",
|
||||
Property::IntrospectionClientId => "introspectionClientId",
|
||||
Property::IntrospectionClientSecret => "introspectionClientSecret",
|
||||
Property::RequireClientRegistration => "requireClientRegistration",
|
||||
Property::RequireScopes => "requireScopes",
|
||||
Property::RequireTls => "requireTls",
|
||||
@@ -3019,6 +3029,10 @@ impl EnumImpl for Property {
|
||||
123 => Some(Property::RequestTlsCertificate),
|
||||
551 => Some(Property::Require),
|
||||
607 => Some(Property::RequireAudience),
|
||||
935 => Some(Property::OpaqueTokens),
|
||||
936 => Some(Property::IntrospectionUrl),
|
||||
937 => Some(Property::IntrospectionClientId),
|
||||
938 => Some(Property::IntrospectionClientSecret),
|
||||
615 => Some(Property::RequireClientRegistration),
|
||||
608 => Some(Property::RequireScopes),
|
||||
525 => Some(Property::RequireTls),
|
||||
@@ -3230,7 +3244,7 @@ impl EnumImpl for Property {
|
||||
}
|
||||
}
|
||||
|
||||
const COUNT: usize = 935;
|
||||
const COUNT: usize = 939;
|
||||
}
|
||||
|
||||
impl serde::Serialize for Property {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
// This file is auto-generated. Do not edit directly.
|
||||
@@ -4142,6 +4144,15 @@ pub struct OidcDirectory {
|
||||
pub claim_groups: Option<String>,
|
||||
#[serde(rename = "memberTenantId")]
|
||||
pub member_tenant_id: Option<Id>,
|
||||
// inbuxa: http-directory-services-13
|
||||
#[serde(rename = "opaqueTokens")]
|
||||
pub opaque_tokens: Option<OidcOpaqueTokens>,
|
||||
#[serde(rename = "introspectionUrl")]
|
||||
pub introspection_url: Option<String>,
|
||||
#[serde(rename = "introspectionClientId")]
|
||||
pub introspection_client_id: Option<String>,
|
||||
#[serde(rename = "introspectionClientSecret")]
|
||||
pub introspection_client_secret: SecretKeyOptional,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -4069,7 +4069,9 @@ impl RegistryJsonPropertyPatch for BlockedIp {
|
||||
|
||||
impl ObjectImpl for Bootstrap {
|
||||
const FLAGS: u64 = OBJ_SINGLETON;
|
||||
const VERSION: u8 = 1;
|
||||
// inbuxa: 2, as x:Directory: OidcDirectory gained fields at version 2
|
||||
// of both
|
||||
const VERSION: u8 = 2;
|
||||
const OBJECT: ObjectType = ObjectType::Bootstrap;
|
||||
|
||||
fn validate(&self, errors: &mut Vec<ValidationError>) -> bool {
|
||||
@@ -6990,7 +6992,9 @@ impl RegistryJsonPropertyPatch for DeliveryError {
|
||||
|
||||
impl ObjectImpl for Directory {
|
||||
const FLAGS: u64 = OBJ_FILTER_TENANT;
|
||||
const VERSION: u8 = 0;
|
||||
// inbuxa: 2, as x:Bootstrap: OidcDirectory gained fields at version 2
|
||||
// of both
|
||||
const VERSION: u8 = 2;
|
||||
const OBJECT: ObjectType = ObjectType::Directory;
|
||||
|
||||
fn validate(&self, errors: &mut Vec<ValidationError>) -> bool {
|
||||
@@ -30639,6 +30643,18 @@ impl OidcDirectory {
|
||||
errors.push(ValidationError::required(Property::MemberTenantId));
|
||||
}
|
||||
}
|
||||
if let Some(value) = &self.introspection_url {
|
||||
if value.is_empty() {
|
||||
errors.push(ValidationError::required(Property::IntrospectionUrl));
|
||||
}
|
||||
}
|
||||
if let Some(value) = &self.introspection_client_id {
|
||||
if value.is_empty() {
|
||||
errors.push(ValidationError::required(Property::IntrospectionClientId));
|
||||
}
|
||||
}
|
||||
let value = &self.introspection_client_secret;
|
||||
value.validate(errors);
|
||||
errors.len() == neb
|
||||
}
|
||||
|
||||
@@ -30661,6 +30677,10 @@ impl Pickle for OidcDirectory {
|
||||
self.claim_name.pickle(out);
|
||||
self.claim_groups.pickle(out);
|
||||
self.member_tenant_id.pickle(out);
|
||||
self.opaque_tokens.pickle(out);
|
||||
self.introspection_url.pickle(out);
|
||||
self.introspection_client_id.pickle(out);
|
||||
self.introspection_client_secret.pickle(out);
|
||||
}
|
||||
|
||||
fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option<Self> {
|
||||
@@ -30674,6 +30694,18 @@ impl Pickle for OidcDirectory {
|
||||
this.claim_name = Pickle::unpickle(stream)?;
|
||||
this.claim_groups = Pickle::unpickle(stream)?;
|
||||
this.member_tenant_id = Pickle::unpickle(stream)?;
|
||||
// inbuxa: http-directory-services-13: version 2 of both x:Directory
|
||||
// and x:Bootstrap, the two objects this is stored in. A directory
|
||||
// saved before keeps accepting opaque tokens its user info endpoint
|
||||
// accepts, as it always did; a new one refuses them by default.
|
||||
if stream.version() >= 2 {
|
||||
this.opaque_tokens = Pickle::unpickle(stream)?;
|
||||
this.introspection_url = Pickle::unpickle(stream)?;
|
||||
this.introspection_client_id = Pickle::unpickle(stream)?;
|
||||
this.introspection_client_secret = Pickle::unpickle(stream)?;
|
||||
} else {
|
||||
this.opaque_tokens = Some(OidcOpaqueTokens::Userinfo);
|
||||
}
|
||||
Some(this)
|
||||
}
|
||||
}
|
||||
@@ -30690,13 +30722,17 @@ impl Default for OidcDirectory {
|
||||
claim_name: Some("name".to_string()),
|
||||
claim_groups: Default::default(),
|
||||
member_tenant_id: Default::default(),
|
||||
opaque_tokens: Default::default(),
|
||||
introspection_url: Default::default(),
|
||||
introspection_client_id: Default::default(),
|
||||
introspection_client_secret: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoValue for OidcDirectory {
|
||||
fn into_value(self) -> JmapValue<'static> {
|
||||
let mut map = jmap_tools::Map::with_capacity(11);
|
||||
let mut map = jmap_tools::Map::with_capacity(15);
|
||||
map.insert_unchecked(Property::Description, self.description.into_value());
|
||||
map.insert_unchecked(Property::IssuerUrl, self.issuer_url.into_value());
|
||||
map.insert_unchecked(
|
||||
@@ -30709,6 +30745,19 @@ impl IntoValue for OidcDirectory {
|
||||
map.insert_unchecked(Property::ClaimName, self.claim_name.into_value());
|
||||
map.insert_unchecked(Property::ClaimGroups, self.claim_groups.into_value());
|
||||
map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value());
|
||||
map.insert_unchecked(Property::OpaqueTokens, self.opaque_tokens.into_value());
|
||||
map.insert_unchecked(
|
||||
Property::IntrospectionUrl,
|
||||
self.introspection_url.into_value(),
|
||||
);
|
||||
map.insert_unchecked(
|
||||
Property::IntrospectionClientId,
|
||||
self.introspection_client_id.into_value(),
|
||||
);
|
||||
map.insert_unchecked(
|
||||
Property::IntrospectionClientSecret,
|
||||
self.introspection_client_secret.into_value(),
|
||||
);
|
||||
JmapValue::Object(map)
|
||||
}
|
||||
}
|
||||
@@ -30745,6 +30794,16 @@ impl RegistryJsonPropertyPatch for OidcDirectory {
|
||||
Some(Property::MemberTenantId) => self
|
||||
.member_tenant_id
|
||||
.patch(pointer.assert_can_set_tenant()?, value),
|
||||
Some(Property::OpaqueTokens) => self.opaque_tokens.patch(pointer, value),
|
||||
Some(Property::IntrospectionUrl) => self
|
||||
.introspection_url
|
||||
.patch(pointer.with_validators(&[StringValidator::Trim]), value),
|
||||
Some(Property::IntrospectionClientId) => self
|
||||
.introspection_client_id
|
||||
.patch(pointer.with_validators(&[StringValidator::Trim]), value),
|
||||
Some(Property::IntrospectionClientSecret) => {
|
||||
self.introspection_client_secret.patch(pointer, value)
|
||||
}
|
||||
Some(Property::Type) => Ok(MaybeUnpatched::Unpatched {
|
||||
property: Property::Type,
|
||||
value,
|
||||
|
||||
@@ -9,14 +9,18 @@
|
||||
use crate::core::Session;
|
||||
use common::{
|
||||
auth::AuthRequest,
|
||||
config::smtp::session::Mechanism,
|
||||
network::{SessionStream, legacy::LegacyProtocol},
|
||||
};
|
||||
use directory::Credentials;
|
||||
use mail_parser::{HeaderName, HeaderValue, decoders::base64::base64_decode};
|
||||
use registry::schema::enums::Permission;
|
||||
use smtp_proto::{AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, IntoString};
|
||||
use smtp_proto::{AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH, AUTH_XOAUTH2, IntoString};
|
||||
use trc::AuthEvent;
|
||||
|
||||
// inbuxa: mechanisms that send a bearer token, a credential on its own
|
||||
pub const AUTH_BEARER: u64 = AUTH_OAUTHBEARER | AUTH_XOAUTH | AUTH_XOAUTH2;
|
||||
|
||||
pub struct SaslToken {
|
||||
mechanism: u64,
|
||||
credentials: Credentials,
|
||||
@@ -48,6 +52,27 @@ impl SaslToken {
|
||||
}
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
/// The mechanisms this session may use: the configured ones, less the
|
||||
/// bearer token ones on clear text whatever the configuration says.
|
||||
pub async fn sasl_mechanisms(&self) -> u64 {
|
||||
let mechanisms: u64 = self
|
||||
.server
|
||||
.eval_if::<Mechanism, _>(
|
||||
&self.server.core.smtp.session.auth.mechanisms,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into();
|
||||
|
||||
if self.stream.is_tls() {
|
||||
mechanisms
|
||||
} else {
|
||||
mechanisms & !AUTH_BEARER
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_sasl_response(
|
||||
&mut self,
|
||||
token: &mut SaslToken,
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{core::Session, scripts::ScriptResult};
|
||||
use common::{
|
||||
config::smtp::session::{Mechanism, Stage},
|
||||
network::SessionStream,
|
||||
};
|
||||
use common::{config::smtp::session::Stage, network::SessionStream};
|
||||
use mail_auth::{
|
||||
SpfResult,
|
||||
spf::verify::{HasValidLabels, SpfParameters},
|
||||
@@ -149,7 +148,6 @@ impl<T: SessionStream> Session<T> {
|
||||
response.capabilities |= EXT_START_TLS;
|
||||
}
|
||||
let ec = &self.server.core.smtp.session.extensions;
|
||||
let ac = &self.server.core.smtp.session.auth;
|
||||
let dc = &self.server.core.smtp.session.data;
|
||||
|
||||
// Pipelining
|
||||
@@ -214,12 +212,8 @@ impl<T: SessionStream> Session<T> {
|
||||
|
||||
// Authentication
|
||||
if !self.is_authenticated() {
|
||||
response.auth_mechanisms = self
|
||||
.server
|
||||
.eval_if::<Mechanism, _>(&ac.mechanisms, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into();
|
||||
// inbuxa: no bearer token mechanism on clear text
|
||||
response.auth_mechanisms = self.sasl_mechanisms().await;
|
||||
if response.auth_mechanisms != 0 {
|
||||
response.capabilities |= EXT_AUTH;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
use common::{
|
||||
config::{server::ServerProtocol, smtp::session::Mechanism},
|
||||
config::server::ServerProtocol,
|
||||
expr::{self, functions::ResolveVariable, *},
|
||||
network::SessionStream,
|
||||
};
|
||||
@@ -26,7 +26,7 @@ use trc::{NetworkEvent, SecurityEvent, SmtpEvent};
|
||||
|
||||
use crate::core::{Session, State};
|
||||
|
||||
use super::auth::SaslToken;
|
||||
use super::auth::{AUTH_BEARER, SaslToken};
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn ingest(&mut self, bytes: &[u8]) -> Result<bool, ()> {
|
||||
@@ -102,16 +102,8 @@ impl<T: SessionStream> Session<T> {
|
||||
mechanism,
|
||||
initial_response,
|
||||
} => {
|
||||
let auth: u64 = self
|
||||
.server
|
||||
.eval_if::<Mechanism, _>(
|
||||
&self.server.core.smtp.session.auth.mechanisms,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into();
|
||||
// inbuxa: no bearer token mechanism on clear text
|
||||
let auth = self.sasl_mechanisms().await;
|
||||
if auth == 0 {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::AuthNotAllowed),
|
||||
@@ -127,6 +119,15 @@ impl<T: SessionStream> Session<T> {
|
||||
);
|
||||
|
||||
self.write(b"503 5.5.1 Already authenticated.\r\n").await?;
|
||||
} else if mechanism & AUTH_BEARER != 0 && !self.stream.is_tls() {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::AuthMechanismNotSupported),
|
||||
SpanId = self.data.session_id,
|
||||
Details = "Bearer tokens require TLS",
|
||||
);
|
||||
|
||||
self.write(b"538 5.7.11 Bearer tokens require TLS.\r\n")
|
||||
.await?;
|
||||
} else if let Some(mut token) =
|
||||
SaslToken::from_mechanism(mechanism & auth)
|
||||
{
|
||||
|
||||
@@ -148,13 +148,17 @@ Enterprise upstream. Feature 1 covers that field. `TenantStorageQuota` has
|
||||
|---|---|---|
|
||||
| `description` | string | Label |
|
||||
| `issuerUrl` | URI | The provider's issuer. Discovery is fetched from `<issuerUrl>/.well-known/openid-configuration` |
|
||||
| `requireAudience` | string, nullable | If set, a token's `aud` must include it |
|
||||
| `requireAudience` | string, nullable | A token's `aud` must include it. Required on a new directory; one saved without it keeps working, logs a warning each time it loads, and can't have an audience removed once set |
|
||||
| `requireScopes` | set of strings, default `openid`, `email` | Every listed scope must be in the token |
|
||||
| `claimUsername` | string, default `preferred_username` | Claim that names the account |
|
||||
| `usernameDomain` | string, nullable | Appended as `@domain` when the username claim has no `@`. Also appended to bare group names |
|
||||
| `claimName` | string, nullable, default `name` | Claim for the display name |
|
||||
| `claimGroups` | string, nullable | Claim for group memberships |
|
||||
| `memberTenantId` | `Id<x:Tenant>`, nullable | Tenant (Feature 1) |
|
||||
| `opaqueTokens` | `reject`, `userinfo` or `introspection`, nullable | What a token that isn't a JWT signs in by (DIR-27). Unset: `introspection` with an `introspectionUrl`, else `reject`. A directory saved before the field existed reads as `userinfo` |
|
||||
| `introspectionUrl` | URI, nullable | RFC 7662 endpoint. Unset: the discovery document's `introspection_endpoint` |
|
||||
| `introspectionClientId` | string, nullable | Client ID introspection authenticates with. Required for `introspection` |
|
||||
| `introspectionClientSecret` | `x:SecretKeyOptional` | Its secret. Required for `introspection` |
|
||||
|
||||
The LDAP and SQL variants are unchanged, and this feature adds nothing to
|
||||
them.
|
||||
@@ -353,16 +357,30 @@ check it and the fork keeps it.
|
||||
- `HS*` algorithms are refused, and HMAC keys in the JWKS are skipped. RSA
|
||||
(RS and PS), EC P-256 and P-384, and EdDSA are accepted.
|
||||
- The key is found by `kid`. An unknown `kid` refetches the JWKS, at most
|
||||
once every 300 seconds. With no `kid`, every key is tried.
|
||||
once every 300 seconds. With no `kid`, every key is tried, and if none
|
||||
verifies the signature the JWKS is refetched once, under the same limit.
|
||||
- Discovery, JWKS, userinfo and introspection responses are read up to
|
||||
1 MiB; a larger one is a provider error.
|
||||
- `iss` must equal the discovery document's issuer.
|
||||
- `exp` is checked, with 60 seconds of leeway.
|
||||
- If `requireAudience` is set, `aud` must be present and include it.
|
||||
- Every `requireScopes` entry must appear in `scope`, which may be a
|
||||
space-separated string or an array.
|
||||
- **DIR-27. Opaque tokens.** A token that isn't a JWT is sent to the
|
||||
provider's userinfo endpoint (OIDC Core §5.3). `401` or `403` means sign-in
|
||||
fails. Token introspection (RFC 7662) isn't used (public docs), and no
|
||||
audience or scope check is possible on this path (open question 2).
|
||||
- **DIR-27. Opaque tokens.** A token that isn't a JWT signs in by the
|
||||
directory's `opaqueTokens`:
|
||||
- `reject`: it doesn't.
|
||||
- `introspection`: it is posted to the introspection endpoint (RFC 7662)
|
||||
with HTTP Basic client authentication (RFC 6749 §2.3.1). Only
|
||||
`"active": true` with `iss` equal to the discovery issuer, `aud`
|
||||
including `requireAudience` when set, `exp` not past, no ID or refresh
|
||||
marker (as for JWTs) and every `requireScopes` entry signs in. A non-2xx
|
||||
answer is a provider error, not a failed sign-in. The claims are then
|
||||
used as a JWT's are (DIR-28).
|
||||
- `userinfo`: it is sent to the provider's userinfo endpoint (OIDC Core
|
||||
§5.3), and `401` or `403` means sign-in fails. No audience or scope check
|
||||
is possible, and any token the provider issued to any application
|
||||
passes; only directories saved before `opaqueTokens` existed get this
|
||||
without choosing it.
|
||||
- **DIR-28. Identity.**
|
||||
- The account address is the `claimUsername` claim if it contains `@`.
|
||||
Otherwise it is that claim plus `@usernameDomain`, or failing that the
|
||||
@@ -512,6 +530,9 @@ data.
|
||||
introspection (which needs client credentials the schema has no field
|
||||
for)? The docs name `requireAudience`'s default as `stalwart`, and the
|
||||
schema has no default. Observe which one a new directory gets.
|
||||
**Settled** (http-directory-services-13): a new directory must set
|
||||
`requireAudience`, and `opaqueTokens` offers introspection; see the
|
||||
table and DIR-27.
|
||||
3. **Local credentials outlive the directory.** App passwords and API keys
|
||||
keep working for a user disabled in the directory (DIR-3). Should
|
||||
synchronization, or a failed directory sign-in, suspend them?
|
||||
|
||||
@@ -1649,6 +1649,8 @@ secret = ["credential"]
|
||||
|
||||
[object."x:OidcDirectory"]
|
||||
default = "none"
|
||||
[object."x:OidcDirectory".properties]
|
||||
introspectionClientSecret = ["credential"]
|
||||
|
||||
[object."x:OidcProvider"]
|
||||
default = "none"
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
F0Ba3aWEThPbP2e1Uy6eryGOZ-UxeLsPNJn9y2yGp4Y
|
||||
sumxHhh5m_-OICu3KBWCIwgVgSpXxw-jcNAvXt63ATc
|
||||
+178
-5
@@ -10,23 +10,24 @@
|
||||
//! runs from `directory_tests`. Each check names its test number or
|
||||
//! requirement.
|
||||
|
||||
use crate::utils::{server::TestServerBuilder, smtp::SmtpConnection};
|
||||
use crate::utils::{registry::UnwrapRegistryId, server::TestServerBuilder, smtp::SmtpConnection};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use common::{BuildServer, auth::AuthRequest};
|
||||
use directory::Credentials;
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::TenantStorageQuota,
|
||||
enums::{OidcOpaqueTokens, TenantStorageQuota},
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
self, CertificateManagement, DkimManagement, DnsManagement, Domain, Expression,
|
||||
MtaStageRcpt, OidcDirectory, Tenant, UserAccount,
|
||||
MtaStageRcpt, OidcDirectory, SecretKeyOptional, SecretKeyValue, Tenant, UserAccount,
|
||||
},
|
||||
},
|
||||
types::map::Map,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::net::IpAddr;
|
||||
use store::registry::write::RegistryWrite;
|
||||
use trc::{Collector, MetricType};
|
||||
use types::id::Id;
|
||||
use utils::map::vec_map::VecMap;
|
||||
@@ -55,6 +56,14 @@ fn realm_directory(realm: &str, tenant: Option<Id>) -> structs::Directory {
|
||||
"openid".to_string(),
|
||||
]),
|
||||
member_tenant_id: tenant,
|
||||
// http-directory-services-13: opaque tokens go to Keycloak's
|
||||
// introspection endpoint, found by discovery
|
||||
opaque_tokens: Some(OidcOpaqueTokens::Introspection),
|
||||
introspection_url: None,
|
||||
introspection_client_id: Some("stalwart".to_string()),
|
||||
introspection_client_secret: SecretKeyOptional::Value(SecretKeyValue {
|
||||
secret: "stalwart-secret".to_string(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,6 +178,23 @@ pub async fn test() {
|
||||
|
||||
// example.org signs in against Keycloak; there is no server default
|
||||
let keycloak = admin.registry_create_object(directory(None)).await;
|
||||
|
||||
// http-directory-services-13: a new directory names the audience its
|
||||
// tokens must be issued for, or tokens minted for any other application
|
||||
// at the provider would sign in
|
||||
let mut no_audience = directory(None);
|
||||
if let structs::Directory::Oidc(oidc) = &mut no_audience {
|
||||
oidc.require_audience = None;
|
||||
}
|
||||
let refused = admin.registry_create_object_expect_err(no_audience).await;
|
||||
assert!(
|
||||
refused
|
||||
.description
|
||||
.as_deref()
|
||||
.is_some_and(|reason| reason.contains("audience")),
|
||||
"a new directory without an audience: {refused:?}"
|
||||
);
|
||||
|
||||
let domain = admin.find_or_create_domain("example.org").await;
|
||||
admin
|
||||
.registry_update_object(
|
||||
@@ -222,6 +248,153 @@ pub async fn test() {
|
||||
"test 5: the named user"
|
||||
);
|
||||
|
||||
// http-directory-services-13: a directory saved before the audience was
|
||||
// required keeps signing people in, and can still be edited without
|
||||
// one; an audience, once set, stays. It also still takes opaque tokens
|
||||
// its user info endpoint accepts, as it always did.
|
||||
let mut legacy = directory(None);
|
||||
if let structs::Directory::Oidc(oidc) = &mut legacy {
|
||||
oidc.description = "Keycloak, saved without an audience".to_string();
|
||||
oidc.require_audience = None;
|
||||
oidc.opaque_tokens = Some(OidcOpaqueTokens::Userinfo);
|
||||
oidc.introspection_client_id = None;
|
||||
oidc.introspection_client_secret = SecretKeyOptional::None;
|
||||
}
|
||||
let legacy = test
|
||||
.server
|
||||
.registry()
|
||||
.write(RegistryWrite::insert(&legacy.into()))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
admin.reload_settings().await;
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Directory,
|
||||
legacy,
|
||||
json!({ Property::Description: "Keycloak, still without an audience" }),
|
||||
)
|
||||
.await;
|
||||
let sign_in_through = async |directory: Id, token: &str| {
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Domain,
|
||||
domain,
|
||||
json!({ Property::DirectoryId: directory.to_string() }),
|
||||
)
|
||||
.await;
|
||||
// In test mode a leading "." makes any token opaque
|
||||
let result = bearer(&test, Some(JOHN), token).await;
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Domain,
|
||||
domain,
|
||||
json!({ Property::DirectoryId: keycloak.to_string() }),
|
||||
)
|
||||
.await;
|
||||
result
|
||||
};
|
||||
let fresh = token("stalwart", "stalwart-secret", JOHN, "openid email profile")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sign_in_through(legacy, &fresh).await.unwrap(),
|
||||
john,
|
||||
"a directory saved without an audience"
|
||||
);
|
||||
assert_eq!(
|
||||
sign_in_through(legacy, &format!(".{fresh}")).await.unwrap(),
|
||||
john,
|
||||
"an opaque token, through a directory saved before the policy"
|
||||
);
|
||||
|
||||
// ... a new directory that doesn't say refuses opaque tokens
|
||||
let mut unset = directory(None);
|
||||
if let structs::Directory::Oidc(oidc) = &mut unset {
|
||||
oidc.description = "Keycloak, opaque tokens unset".to_string();
|
||||
oidc.opaque_tokens = None;
|
||||
oidc.introspection_client_id = None;
|
||||
oidc.introspection_client_secret = SecretKeyOptional::None;
|
||||
}
|
||||
let unset = admin.registry_create_object(unset).await;
|
||||
assert!(
|
||||
sign_in_through(unset, &format!(".{fresh}")).await.is_err(),
|
||||
"an opaque token, through a new directory"
|
||||
);
|
||||
assert_eq!(
|
||||
sign_in_through(unset, &fresh).await.unwrap(),
|
||||
john,
|
||||
"a JWT, through a new directory"
|
||||
);
|
||||
|
||||
// ... and introspection takes only an active access token
|
||||
assert_eq!(
|
||||
bearer(&test, Some(JOHN), &format!(".{fresh}"))
|
||||
.await
|
||||
.unwrap(),
|
||||
john,
|
||||
"an opaque token, by introspection"
|
||||
);
|
||||
let refresh = reqwest::Client::new()
|
||||
.post("http://localhost:9080/realms/stalwart/protocol/openid-connect/token")
|
||||
.form(&[
|
||||
("grant_type", "password"),
|
||||
("client_id", "stalwart"),
|
||||
("client_secret", "stalwart-secret"),
|
||||
("username", JOHN),
|
||||
("password", PASSWORD),
|
||||
("scope", "openid email profile"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.unwrap()["refresh_token"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
for (what, token) in [
|
||||
("a refresh token", format!(".{refresh}")),
|
||||
(
|
||||
"a token the provider never issued",
|
||||
".never-issued".to_string(),
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
bearer(&test, Some(JOHN), &token).await.is_err(),
|
||||
"by introspection: {what}"
|
||||
);
|
||||
}
|
||||
let refused = admin
|
||||
.registry_update_object_expect_err(
|
||||
ObjectType::Directory,
|
||||
keycloak,
|
||||
json!({ Property::IntrospectionClientId: null }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
refused
|
||||
.description
|
||||
.as_deref()
|
||||
.is_some_and(|reason| reason.contains("client ID")),
|
||||
"introspection without a client: {refused:?}"
|
||||
);
|
||||
let refused = admin
|
||||
.registry_update_object_expect_err(
|
||||
ObjectType::Directory,
|
||||
keycloak,
|
||||
json!({ Property::RequireAudience: null }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
refused
|
||||
.description
|
||||
.as_deref()
|
||||
.is_some_and(|reason| reason.contains("audience")),
|
||||
"removing the audience: {refused:?}"
|
||||
);
|
||||
|
||||
// Test 17: no password sign-in on an OIDC domain (DIR-29)
|
||||
assert!(
|
||||
server(&test)
|
||||
@@ -395,8 +568,8 @@ pub async fn test() {
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
// An opaque token has to reach the provider's userinfo endpoint, so with
|
||||
// the provider stopped this is an outage, not a bad token
|
||||
// An opaque token has to reach the provider's introspection endpoint, so
|
||||
// with the provider stopped this is an outage, not a bad token
|
||||
crate::utils::containers::docker("stop", "stalwart-test-keycloak");
|
||||
for attempt in 0..8 {
|
||||
let err = bearer(&test, Some(JOHN), "an-opaque-token")
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use directory::{Credentials, Directory, UnavailableDirectory, backend::oidc::OpenIdDirectory};
|
||||
@@ -27,6 +29,7 @@ pub async fn test() {
|
||||
require_audience: None,
|
||||
require_scopes: Map::new(vec![]),
|
||||
member_tenant_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect_err("Discovery against an unreachable issuer must fail");
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! inbuxa: bearer tokens are never offered or accepted on a clear-text
|
||||
//! connection, even with plain-text sign-in allowed. Password sign-in
|
||||
//! there keeps following that setting.
|
||||
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines},
|
||||
net::{TcpStream, tcp::OwnedReadHalf},
|
||||
};
|
||||
|
||||
// Expects plain-text sign-in to be allowed, as the imap suite leaves it.
|
||||
pub async fn test() {
|
||||
println!("Running clear-text bearer token tests...");
|
||||
|
||||
let token = general_purpose::STANDARD
|
||||
.encode("n,[email protected],\u{1}auth=Bearer not-a-real-token\u{1}\u{1}");
|
||||
let mut problems = Vec::new();
|
||||
|
||||
// IMAP, clear text with STARTTLS available
|
||||
let (mut rx, mut tx) = connect("127.0.0.1:9991").await;
|
||||
let greeting = rx.next_line().await.unwrap().unwrap();
|
||||
check_offer(
|
||||
&mut problems,
|
||||
"IMAP greeting",
|
||||
&greeting,
|
||||
"AUTH=PLAIN",
|
||||
&["AUTH=OAUTHBEARER", "AUTH=XOAUTH2"],
|
||||
);
|
||||
tx.write_all(b"a CAPABILITY\r\n").await.unwrap();
|
||||
let capability = read_until(&mut rx, "a ").await.join("\n");
|
||||
check_offer(
|
||||
&mut problems,
|
||||
"IMAP CAPABILITY",
|
||||
&capability,
|
||||
"AUTH=PLAIN",
|
||||
&["AUTH=OAUTHBEARER", "AUTH=XOAUTH2"],
|
||||
);
|
||||
tx.write_all(format!("b AUTHENTICATE OAUTHBEARER {token}\r\n").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
let reply = read_until(&mut rx, "b ").await.join("\n");
|
||||
if !reply.contains("b NO [PRIVACYREQUIRED]") {
|
||||
problems.push(format!(
|
||||
"IMAP AUTHENTICATE OAUTHBEARER not refused: {reply}"
|
||||
));
|
||||
}
|
||||
tx.write_all(b"c AUTHENTICATE XOAUTH2\r\n").await.unwrap();
|
||||
let reply = rx.next_line().await.unwrap().unwrap();
|
||||
if !reply.starts_with("c NO [PRIVACYREQUIRED]") {
|
||||
problems.push(format!("IMAP AUTHENTICATE XOAUTH2 not refused: {reply}"));
|
||||
}
|
||||
drop(tx);
|
||||
|
||||
// POP3, clear text
|
||||
let (mut rx, mut tx) = connect("127.0.0.1:4111").await;
|
||||
assert!(rx.next_line().await.unwrap().unwrap().starts_with("+OK"));
|
||||
tx.write_all(b"CAPA\r\n").await.unwrap();
|
||||
let capa = read_until(&mut rx, ".").await.join("\n");
|
||||
check_offer(
|
||||
&mut problems,
|
||||
"POP3 CAPA",
|
||||
&capa,
|
||||
"SASL PLAIN",
|
||||
&["OAUTHBEARER", "XOAUTH2"],
|
||||
);
|
||||
for (mechanism, param) in [("OAUTHBEARER", token.as_str()), ("XOAUTH2", "")] {
|
||||
tx.write_all(
|
||||
format!("AUTH {mechanism} {param}\r\n")
|
||||
.trim_end()
|
||||
.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.write_all(b"\r\n").await.unwrap();
|
||||
let reply = rx.next_line().await.unwrap().unwrap();
|
||||
if !(reply.starts_with("-ERR") && reply.contains("TLS")) {
|
||||
problems.push(format!("POP3 AUTH {mechanism} not refused: {reply}"));
|
||||
}
|
||||
}
|
||||
drop(tx);
|
||||
|
||||
// ManageSieve, clear text with STARTTLS available
|
||||
let (mut rx, mut tx) = connect("127.0.0.1:4191").await;
|
||||
let greeting = read_until(&mut rx, "OK").await.join("\n");
|
||||
check_offer(
|
||||
&mut problems,
|
||||
"ManageSieve greeting",
|
||||
&greeting,
|
||||
"PLAIN",
|
||||
&["OAUTHBEARER", "XOAUTH2"],
|
||||
);
|
||||
for (mechanism, param) in [
|
||||
("OAUTHBEARER", format!(" \"{token}\"")),
|
||||
("XOAUTH2", "".into()),
|
||||
] {
|
||||
tx.write_all(format!("AUTHENTICATE \"{mechanism}\"{param}\r\n").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
let reply = rx.next_line().await.unwrap().unwrap();
|
||||
if !reply.starts_with("NO (ENCRYPT-NEEDED)") {
|
||||
problems.push(format!(
|
||||
"ManageSieve AUTHENTICATE {mechanism} not refused: {reply}"
|
||||
));
|
||||
}
|
||||
}
|
||||
drop(tx);
|
||||
|
||||
assert!(
|
||||
problems.is_empty(),
|
||||
"bearer tokens on clear text:\n{}",
|
||||
problems.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
fn check_offer(problems: &mut Vec<String>, what: &str, text: &str, keep: &str, drop: &[&str]) {
|
||||
if !text.contains(keep) {
|
||||
problems.push(format!("{what} lost {keep}: {text}"));
|
||||
}
|
||||
for mechanism in drop {
|
||||
if text.contains(mechanism) {
|
||||
problems.push(format!("{what} offered {mechanism}: {text}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect(
|
||||
addr: &str,
|
||||
) -> (
|
||||
Lines<BufReader<OwnedReadHalf>>,
|
||||
tokio::net::tcp::OwnedWriteHalf,
|
||||
) {
|
||||
let (rx, tx) = TcpStream::connect(addr).await.unwrap().into_split();
|
||||
(BufReader::new(rx).lines(), tx)
|
||||
}
|
||||
|
||||
async fn read_until(rx: &mut Lines<BufReader<OwnedReadHalf>>, prefix: &str) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
loop {
|
||||
let line = rx.next_line().await.unwrap().unwrap();
|
||||
let done = line.starts_with(prefix);
|
||||
lines.push(line);
|
||||
if done {
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod acl;
|
||||
pub mod antispam;
|
||||
pub mod append;
|
||||
pub mod basic;
|
||||
pub mod bearer_tls;
|
||||
pub mod body_structure;
|
||||
pub mod condstore;
|
||||
pub mod copy_move;
|
||||
@@ -19,6 +20,7 @@ pub mod mailbox;
|
||||
pub mod managesieve;
|
||||
pub mod messagelimit;
|
||||
pub mod objectid;
|
||||
pub mod pipelining;
|
||||
pub mod pop;
|
||||
pub mod search;
|
||||
pub mod store;
|
||||
@@ -55,6 +57,13 @@ pub async fn imap_tests() {
|
||||
.await
|
||||
.with_listener(NetworkListenerProtocol::Pop3, "pop3-plain", 4111, false)
|
||||
.await
|
||||
.with_listener(
|
||||
NetworkListenerProtocol::ManageSieve,
|
||||
"sieve-plain",
|
||||
4191,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -266,6 +275,7 @@ pub async fn imap_tests() {
|
||||
acl::test(&mut imap, &mut imap_check, &test).await;
|
||||
uidbatches::test(&mut imap, &mut imap_check).await;
|
||||
messagelimit::test(&mut imap, &mut imap_check).await;
|
||||
pipelining::test(&test).await;
|
||||
|
||||
// UIDONLY cannot be disabled once enabled, so it uses its own connection
|
||||
uidonly::test(&test).await;
|
||||
@@ -288,6 +298,9 @@ pub async fn imap_tests() {
|
||||
// Run POP3 tests
|
||||
pop::test(&test).await;
|
||||
|
||||
// Bearer tokens on clear-text connections
|
||||
bearer_tls::test().await;
|
||||
|
||||
// Print elapsed time
|
||||
let elapsed = start_time.elapsed();
|
||||
println!(
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! inbuxa: commands a client pipelines in one write run in order. A
|
||||
//! command that changes state waits for the commands before it and the
|
||||
//! commands after it wait for it, so they see its result.
|
||||
|
||||
use crate::utils::server::TestServer;
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use std::time::Duration;
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines},
|
||||
net::{TcpStream, tcp::OwnedReadHalf},
|
||||
};
|
||||
|
||||
const MESSAGES: usize = 8;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
println!("Running pipelined command ordering tests...");
|
||||
|
||||
let account = test.account("[email protected]");
|
||||
let (rx, mut tx) = TcpStream::connect("127.0.0.1:9991")
|
||||
.await
|
||||
.unwrap()
|
||||
.into_split();
|
||||
let mut rx = BufReader::new(rx).lines();
|
||||
rx.next_line().await.unwrap().unwrap();
|
||||
let credentials =
|
||||
general_purpose::STANDARD.encode(format!("\0{}\0{}", account.name(), account.secret()));
|
||||
tx.write_all(
|
||||
format!(
|
||||
"a AUTHENTICATE PLAIN {{{}+}}\r\n{credentials}\r\n",
|
||||
credentials.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let login = read_tags(&mut rx, &["a"]).await;
|
||||
assert!(
|
||||
login
|
||||
.tagged
|
||||
.first()
|
||||
.is_some_and(|line| line.starts_with("a OK")),
|
||||
"sign-in failed: {:?}",
|
||||
login.lines
|
||||
);
|
||||
|
||||
let mut problems = Vec::new();
|
||||
|
||||
// CREATE, then APPENDs into the new mailbox, then STATUS, in one write
|
||||
let mut script = String::from("p00 CREATE \"Pipelined\"\r\n");
|
||||
let mut tags = vec!["p00".to_string()];
|
||||
for i in 1..=MESSAGES {
|
||||
let message =
|
||||
format!("From: [email protected]\r\nSubject: pipelined {i}\r\n\r\nbody {i}\r\n");
|
||||
script.push_str(&format!(
|
||||
"p{i:02} APPEND \"Pipelined\" {{{}+}}\r\n{message}\r\n",
|
||||
message.len()
|
||||
));
|
||||
tags.push(format!("p{i:02}"));
|
||||
}
|
||||
let status_tag = format!("p{:02}", MESSAGES + 1);
|
||||
script.push_str(&format!("{status_tag} STATUS \"Pipelined\" (MESSAGES)\r\n"));
|
||||
tags.push(status_tag);
|
||||
tx.write_all(script.as_bytes()).await.unwrap();
|
||||
let tags = tags.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let result = read_tags(&mut rx, &tags).await;
|
||||
result.check_in_order(&mut problems, "CREATE/APPEND/STATUS", &tags);
|
||||
let expected = format!("MESSAGES {MESSAGES})");
|
||||
if !result
|
||||
.lines
|
||||
.iter()
|
||||
.any(|line| line.starts_with("* STATUS") && line.contains(&expected))
|
||||
{
|
||||
problems.push(format!(
|
||||
"STATUS after the pipelined APPENDs did not report {expected}: {:?}",
|
||||
result.untagged("* STATUS")
|
||||
));
|
||||
}
|
||||
|
||||
// STORE, then FETCH of the same messages, then DELETE, in one write.
|
||||
// SELECT goes first on its own: the commands after it are checked
|
||||
// against the session state as they are read.
|
||||
tx.write_all(b"q1 SELECT \"Pipelined\"\r\n").await.unwrap();
|
||||
let result = read_tags(&mut rx, &["q1"]).await;
|
||||
result.check_in_order(&mut problems, "SELECT", &["q1"]);
|
||||
let tags = ["q2", "q3", "q4", "q5"];
|
||||
tx.write_all(
|
||||
concat!(
|
||||
"q2 STORE 1:* +FLAGS.SILENT (\\Flagged)\r\n",
|
||||
"q3 FETCH 1:* (FLAGS)\r\n",
|
||||
"q4 UNSELECT\r\n",
|
||||
"q5 DELETE \"Pipelined\"\r\n",
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let result = read_tags(&mut rx, &tags).await;
|
||||
result.check_in_order(&mut problems, "STORE/FETCH/UNSELECT/DELETE", &tags);
|
||||
let fetched = result.untagged("FETCH (");
|
||||
if fetched.len() != MESSAGES || fetched.iter().any(|line| !line.contains("\\Flagged")) {
|
||||
problems.push(format!(
|
||||
"FETCH after the pipelined STORE did not see \\Flagged on all {MESSAGES} messages: {fetched:?}"
|
||||
));
|
||||
}
|
||||
|
||||
tx.write_all(b"z LOGOUT\r\n").await.unwrap();
|
||||
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
struct Responses {
|
||||
lines: Vec<String>,
|
||||
tagged: Vec<String>,
|
||||
}
|
||||
|
||||
impl Responses {
|
||||
fn check_in_order(&self, problems: &mut Vec<String>, what: &str, tags: &[&str]) {
|
||||
let order = self
|
||||
.tagged
|
||||
.iter()
|
||||
.map(|line| line.split_once(' ').map_or(line.as_str(), |(tag, _)| tag))
|
||||
.collect::<Vec<_>>();
|
||||
if order != tags {
|
||||
problems.push(format!(
|
||||
"{what}: completions out of order: {:?}",
|
||||
self.tagged
|
||||
));
|
||||
}
|
||||
for line in &self.tagged {
|
||||
if !line.split(' ').nth(1).is_some_and(|status| status == "OK") {
|
||||
problems.push(format!("{what}: command failed: {line:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn untagged(&self, needle: &str) -> Vec<&String> {
|
||||
self.lines
|
||||
.iter()
|
||||
.filter(|line| line.starts_with("* ") && line.contains(needle))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// Reads until every tag has completed, keeping the order they completed in
|
||||
async fn read_tags(rx: &mut Lines<BufReader<OwnedReadHalf>>, tags: &[&str]) -> Responses {
|
||||
let mut responses = Responses {
|
||||
lines: Vec::new(),
|
||||
tagged: Vec::new(),
|
||||
};
|
||||
while responses.tagged.len() < tags.len() {
|
||||
let line = match tokio::time::timeout(Duration::from_secs(15), rx.next_line()).await {
|
||||
Ok(Ok(Some(line))) => line,
|
||||
other => panic!(
|
||||
"no response for all of {tags:?} ({other:?}); got {:?}",
|
||||
responses.lines
|
||||
),
|
||||
};
|
||||
if tags.iter().any(|tag| {
|
||||
line.strip_prefix(tag)
|
||||
.is_some_and(|rest| rest.starts_with(' '))
|
||||
}) {
|
||||
responses.tagged.push(line.clone());
|
||||
}
|
||||
responses.lines.push(line);
|
||||
}
|
||||
responses
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
@@ -57,6 +59,7 @@ async fn bind_directory(test: &TestServer) -> Id {
|
||||
"openid".to_string(),
|
||||
]),
|
||||
member_tenant_id: None,
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
@@ -67,10 +69,17 @@ async fn auth() {
|
||||
else_: "false".into(),
|
||||
},
|
||||
sasl_mechanisms: Expression {
|
||||
match_: List::from_iter([ExpressionMatch {
|
||||
if_: "remote_ip = '10.0.0.1' && is_tls".into(),
|
||||
then: "[plain, login]".into(),
|
||||
}]),
|
||||
match_: List::from_iter([
|
||||
ExpressionMatch {
|
||||
if_: "remote_ip = '10.0.0.1' && is_tls".into(),
|
||||
then: "[plain, login]".into(),
|
||||
},
|
||||
// inbuxa: every mechanism allowed with or without TLS
|
||||
ExpressionMatch {
|
||||
if_: "remote_ip = '10.0.0.3'".into(),
|
||||
then: "[plain, login, oauthbearer, xoauth2]".into(),
|
||||
},
|
||||
]),
|
||||
else_: "0".into(),
|
||||
},
|
||||
wait_on_fail: Expression {
|
||||
@@ -175,4 +184,31 @@ async fn auth() {
|
||||
session
|
||||
.auth_plain("[email protected]", "12345 + extra safety", "503 5.5.1")
|
||||
.await;
|
||||
|
||||
// inbuxa: bearer tokens are neither offered nor accepted without TLS,
|
||||
// even where the configuration allows every mechanism there
|
||||
let mut session = test.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.3".into();
|
||||
session.eval_session_params().await;
|
||||
session.stream.tls = false;
|
||||
session
|
||||
.ehlo("mx.foobar.org")
|
||||
.await
|
||||
.assert_contains(" PLAIN")
|
||||
.assert_not_contains("OAUTHBEARER")
|
||||
.assert_not_contains("XOAUTH2");
|
||||
session
|
||||
.ingest(b"AUTH OAUTHBEARER bixhPWpvaG5AZXhhbXBsZS5vcmcsAWF1dGg9QmVhcmVyIHgBAQ==\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
session.response().assert_code("538 5.7.11");
|
||||
session.ingest(b"AUTH XOAUTH2\r\n").await.unwrap();
|
||||
session.response().assert_code("538 5.7.11");
|
||||
session.stream.tls = true;
|
||||
session
|
||||
.ehlo("mx.foobar.org")
|
||||
.await
|
||||
.assert_contains(" PLAIN")
|
||||
.assert_contains("OAUTHBEARER")
|
||||
.assert_contains("XOAUTH2");
|
||||
}
|
||||
+101
-6
@@ -8,7 +8,7 @@
|
||||
|
||||
use crate::utils::{
|
||||
http::HttpRequest,
|
||||
imap::{ImapConnection, Type},
|
||||
imap::{AssertResult, ImapConnection, Type},
|
||||
pop3::Pop3Connection,
|
||||
server::TestServer,
|
||||
smtp::SmtpConnection,
|
||||
@@ -39,9 +39,15 @@ use registry::schema::{
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{OAuthClient, OidcProvider, SecretText, SecretTextValue},
|
||||
};
|
||||
use rustls_pki_types::ServerName;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::time::{Duration, Instant};
|
||||
use store::ahash::AHashMap;
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader},
|
||||
net::TcpStream,
|
||||
};
|
||||
use utils::tls::build_tls_connector;
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct OAuthMetadata {
|
||||
@@ -560,20 +566,31 @@ pub async fn test(test: &mut TestServer) {
|
||||
"n,a={},\u{1}auth=Bearer {}\u{1}\u{1}",
|
||||
"user@domain", token
|
||||
));
|
||||
// inbuxa: bearer tokens are refused on clear text, so the sign-ins that
|
||||
// should work run over STARTTLS (SMTP) and implicit TLS (IMAP)
|
||||
let mut smtp = SmtpConnection::connect().await;
|
||||
smtp.send(&format!("AUTH OAUTHBEARER {oauth_bearer_invalid_sasl}",))
|
||||
.await;
|
||||
smtp.read(1, 4).await;
|
||||
smtp.send(&format!("AUTH OAUTHBEARER {oauth_bearer_sasl}",))
|
||||
.await;
|
||||
smtp.read(1, 2).await;
|
||||
smtp.read(1, 5).await;
|
||||
let replies = lmtp_starttls_auth(&[&oauth_bearer_invalid_sasl, &oauth_bearer_sasl]).await;
|
||||
assert!(
|
||||
replies[0].starts_with('4') && replies[1].starts_with("235"),
|
||||
"SMTP OAUTHBEARER over TLS: {replies:?}"
|
||||
);
|
||||
|
||||
// Try IMAP OAUTHBEARER auth
|
||||
let mut imap = ImapConnection::connect(b"_x ").await;
|
||||
imap.assert_read(Type::Untagged, ResponseType::Ok).await;
|
||||
imap.send(&format!("AUTHENTICATE OAUTHBEARER {oauth_bearer_sasl}"))
|
||||
.await;
|
||||
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
|
||||
imap.assert_read(Type::Tagged, ResponseType::No)
|
||||
.await
|
||||
.assert_contains("PRIVACYREQUIRED");
|
||||
let reply = imap_tls_auth(&oauth_bearer_sasl).await;
|
||||
assert!(
|
||||
reply.starts_with("a OK"),
|
||||
"IMAP OAUTHBEARER over TLS: {reply}"
|
||||
);
|
||||
|
||||
// Try POP3 OAUTHBEARER auth
|
||||
let mut pop3 = Pop3Connection::connect().await;
|
||||
@@ -1260,3 +1277,81 @@ WiYrLO4z8/kmkqvA7wGElBok9IqhRANCAAQxZK68FnQtHC0eyh8CA05xRIvxhVHn
|
||||
0ymka6XBh9aFtW4wfeoKhTkSKjHc/zjh9Rr2dr3kvmYe80fMGhW4ycGA
|
||||
-----END PRIVATE KEY-----
|
||||
";
|
||||
|
||||
// inbuxa: AUTH OAUTHBEARER with each SASL response on the LMTP port after
|
||||
// STARTTLS, one attempt per response; returns the final reply lines.
|
||||
async fn lmtp_starttls_auth(sasl: &[&str]) -> Vec<String> {
|
||||
let mut stream = BufReader::new(TcpStream::connect("127.0.0.1:11200").await.unwrap());
|
||||
read_smtp_reply(&mut stream).await;
|
||||
stream
|
||||
.get_mut()
|
||||
.write_all(b"LHLO localhost\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
read_smtp_reply(&mut stream).await;
|
||||
stream.get_mut().write_all(b"STARTTLS\r\n").await.unwrap();
|
||||
assert!(read_smtp_reply(&mut stream).await.starts_with("220"));
|
||||
let tls = build_tls_connector(true)
|
||||
.unwrap()
|
||||
.connect(
|
||||
ServerName::try_from("localhost").unwrap().to_owned(),
|
||||
stream.into_inner(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut stream = BufReader::new(tls);
|
||||
stream
|
||||
.get_mut()
|
||||
.write_all(b"LHLO localhost\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
read_smtp_reply(&mut stream).await;
|
||||
let mut replies = Vec::new();
|
||||
for response in sasl {
|
||||
stream
|
||||
.get_mut()
|
||||
.write_all(format!("AUTH OAUTHBEARER {response}\r\n").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
replies.push(read_smtp_reply(&mut stream).await);
|
||||
}
|
||||
replies
|
||||
}
|
||||
|
||||
async fn read_smtp_reply<S: AsyncRead + Unpin>(stream: &mut BufReader<S>) -> String {
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
stream.read_line(&mut line).await.unwrap();
|
||||
if line.len() < 4 || line.as_bytes()[3] == b' ' {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: AUTHENTICATE OAUTHBEARER on the implicit TLS IMAP port; returns the
|
||||
// tagged reply.
|
||||
async fn imap_tls_auth(sasl: &str) -> String {
|
||||
let tls = build_tls_connector(true)
|
||||
.unwrap()
|
||||
.connect(
|
||||
ServerName::try_from("localhost").unwrap().to_owned(),
|
||||
TcpStream::connect("127.0.0.1:9992").await.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut stream = BufReader::new(tls);
|
||||
let mut line = String::new();
|
||||
stream.read_line(&mut line).await.unwrap();
|
||||
stream
|
||||
.get_mut()
|
||||
.write_all(format!("a AUTHENTICATE OAUTHBEARER {sasl}\r\n").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
loop {
|
||||
line.clear();
|
||||
stream.read_line(&mut line).await.unwrap();
|
||||
if line.starts_with("a ") || line.is_empty() {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
_ignore
|
||||
Cargo.lock
|
||||
target
|
||||
web/dist
|
||||
specs/
|
||||
.DS_Store
|
||||
*.md
|
||||
!CHANGELOG.md
|
||||
!README.md
|
||||
|
||||
# inbuxa: the vendoring notes are tracked
|
||||
!VENDORED.md
|
||||
Vendored
+117
@@ -0,0 +1,117 @@
|
||||
calcard 0.3.14
|
||||
================================
|
||||
- Updated JSCalendar conversion rules according to `draft-ietf-calext-jscalendar-icalendar-26` and `draft-ietf-calext-jscalendarbis-20`.
|
||||
- Recurrence overrides converted from iCalendar no longer contain patches forbidden by `jscalendarbis` (`privacy`, `organizerCalendarAddress`, `sentBy`, `recurrenceRule`, nested `recurrenceOverrides`, ...), and such patches are ignored when converting to iCalendar; an override holding only forbidden patches is dropped, and a more restrictive `privacy` patch raises the privacy of the whole object.
|
||||
- All recurrences of a calendar object now convert to a single `privacy` value, taken from the most restrictive `CLASS` among the main component, its recurrence overrides, or its recurrence instances (or among repeated `CLASS` properties in one component).
|
||||
- Unknown `CLASS` values now convert to the `private` privacy, preserving the original property in `iCalendar/properties`.
|
||||
- The `privacy` property now converts to the `CLASS` property of the main component and every recurrence override component, and a preserved `CLASS` with an unknown value is restored instead of `PRIVATE`, never emitting more than one `CLASS` per component.
|
||||
- Recurrence override components exported from JSCalendar now keep the `ORGANIZER` property of the main component when the override's participants include the organizer.
|
||||
- Bump `mail-builder` dependency to 1.0.0 and `uuid` to 1.26.
|
||||
|
||||
calcard 0.3.13
|
||||
================================
|
||||
- Fix: serialized lines exceeded the 75 octet fold (#25).
|
||||
- General performance improvements.
|
||||
|
||||
calcard 0.3.12
|
||||
================================
|
||||
- Added `ICalendar::add_missing_timezones()`, which builds a `VTIMEZONE` component for every `TZID` parameter that has no matching definition, as required by RFC 5545, Section 3.6.5.
|
||||
- Added `ICalendar::add_timezone()`, which builds a `VTIMEZONE` definition for a given time zone identifier over an explicit UTC range.
|
||||
|
||||
calcard 0.3.11
|
||||
================================
|
||||
- Fix: whitespace immediately preceding a fold was discarded while unfolding, silently joining the words around it (RFC 5545, Section 3.1).
|
||||
- Fix: `into_jscalendar()` panicked on `VJOURNAL`, `VFREEBUSY`, `VTIMEZONE` and unknown components, none of which have a JSCalendar type.
|
||||
- Fix: `RRULE` values that could not be parsed were truncated to their first rule part instead of being preserved verbatim.
|
||||
- Fix: unparseable `RRULE` values were serialized with `TEXT` escaping, turning the structural `;` and `,` separators of the `RECUR` value type into `\;` and `\,` (RFC 5545, Section 3.3.10).
|
||||
- Fix: date-only vCard 3.0 `REV` values were serialized with a fabricated `T000000` time, although a `date` value is valid there (RFC 2426, Section 3.6.4).
|
||||
- Fix: truncated iCalendar `DATE-TIME` and vCard `TIMESTAMP` values were not completed, so a value missing its seconds did not survive a round trip, and a value missing its minutes was silently downgraded to a `DATE`, discarding the time.
|
||||
- Fix: control characters were retained in parameter values and emitted verbatim by URI parameters such as `ALTREP` and `DIR`, although `QSAFE-CHAR` does not permit them (RFC 5545, Section 3.1).
|
||||
- Fix: vCard content lines lacking a value separator were parsed with no value but serialized with an empty one.
|
||||
- Fix: serialized lines exceeded the 75 octet limit by one because the colon separating a property from its value was not counted.
|
||||
- Fix: an empty value at a fold boundary emitted a continuation line holding nothing but the fold character.
|
||||
- Fix: vCard 2.1 `BASE64` values continued on unindented lines and terminated by a blank line were truncated to their first line, discarding the rest of the encoded data.
|
||||
- Fix: an `RRULE` holding an invalid rule part silently yielded a partial recurrence rule, since every part after the invalid one was skipped and the resulting rule was returned as if it had parsed cleanly.
|
||||
- Expanded the iCalendar and vCard round-trip test corpus with samples collected from public test suites.
|
||||
|
||||
calcard 0.3.10
|
||||
================================
|
||||
- Bump `mail-builder` dependency to 0.5.
|
||||
- Use `rkyv::primitive::ArchivedU32` instead of the concrete `rkyv::rend::u32_le`.
|
||||
|
||||
calcard 0.3.9
|
||||
================================
|
||||
- Updated JSCalendar conversion rules according to `draft-ietf-calext-jscalendar-icalendar-25`.
|
||||
- Fix: `ORGANIZER` converted to a duplicate Participant object instead of merging with the `ATTENDEE` or `PARTICIPANT` sharing its calendar address (#24).
|
||||
- Fix: the `owner` role was not set on the Participant object converted from `ORGANIZER`.
|
||||
- Fix: `iCalendar` converted properties of Link and Participant objects were matched position rather than by object key when no `JSID` parameter was present.
|
||||
- Fix: all-day events exported as a floating DATE-TIME plus `SHOW-WITHOUT-TIME` instead of the `DATE` value type.
|
||||
- Fix: `VERSION` was missing from the generated `VCALENDAR` component, and the JSCalendar `version` property was exported as a `JSPROP` property.
|
||||
|
||||
calcard 0.3.8
|
||||
================================
|
||||
- Fix: binary values without a media type serialized as `data:base64,`, which is not a valid
|
||||
`data` URL (RFC 2397).
|
||||
- Map the legacy vCard 3.0 `TYPE` parameter of `PHOTO`, `LOGO`, `SOUND` and `KEY` to the media
|
||||
type of their binary value, so upgrading a v3.0 card to v4.0 yields `data:image/jpeg;base64,`
|
||||
instead of a typeless `data` URL.
|
||||
- Include `TYPE=` when exporting binary values to vCard v3.0 and below.
|
||||
- Fix: archived iCalendar writer escaped `,` and `;` inside quoted URI parameter values such as
|
||||
`DIR`.
|
||||
|
||||
calcard 0.3.7
|
||||
================================
|
||||
- Fix: JSCalendar `rscale` not converted to the iCalendar `RSCALE` rule part.
|
||||
- Fix: `mailto:` scheme not stripped from calendar addresses when uppercase or mixed case.
|
||||
|
||||
calcard 0.3.6
|
||||
================================
|
||||
- Fix: `FN` property not generated for vCard when `N` property is present (#22).
|
||||
|
||||
calcard 0.3.5
|
||||
================================
|
||||
- Include 'ENCODING=' when exporting vCard v3.0 and below.
|
||||
|
||||
calcard 0.3.4
|
||||
================================
|
||||
- Include `CHARSET=UTF-8` when exporting vCard v3.0 and below.
|
||||
|
||||
calcard 0.3.3
|
||||
================================
|
||||
- Support `STATUS:CANCELLED` mapping from `VTODO` to JSCalendar (#20).
|
||||
- Fixed duration parsing for zero duration `PT0S`.
|
||||
|
||||
calcard 0.3.2
|
||||
================================
|
||||
- Fixed vCard `CELL` to JSContact `mobile` mapping (#15).
|
||||
- Updated JSCalendar conversion rules according to `draft-ietf-calext-jscalendar-icalendar-21`.
|
||||
|
||||
calcard 0.3.1
|
||||
================================
|
||||
- Fixed jcal implementation to use lowercase property and component names.
|
||||
- Updated conversion rules according to the latest JSCalendar-bis specification.
|
||||
|
||||
calcard 0.3.0
|
||||
================================
|
||||
- JMAP for Calendars support.
|
||||
- JMAP for Contacts support.
|
||||
|
||||
calcard 0.2.0
|
||||
================================
|
||||
- JSCalendar parsing and conversion to iCalendar format.
|
||||
- JSContact parsing and conversion to vCard format.
|
||||
- Fix: `RRULE` with `UNTIL` dates not parsed correctly (#12).
|
||||
- Fix: Support multiple periods in `FREEBUSY` component (#4).
|
||||
- Fix: Incorrect `\ ` conversion (#3).
|
||||
|
||||
calcard 0.1.3
|
||||
================================
|
||||
- Added some builder methods.
|
||||
|
||||
calcard 0.1.1
|
||||
================================
|
||||
- Export vCard in legacy formats.
|
||||
|
||||
calcard 0.1.0
|
||||
================================
|
||||
- Initial release.
|
||||
Vendored
+116
@@ -0,0 +1,116 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2024"
|
||||
name = "calcard"
|
||||
version = "0.3.14"
|
||||
authors = ["Stalwart Labs <[email protected]>"]
|
||||
build = false
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "iCalendar/JSCalendar and vCard/JSContact parsing, building and conversion library for Rust"
|
||||
homepage = "https://github.com/stalwartlabs/calcard"
|
||||
readme = "README.md"
|
||||
keywords = [
|
||||
"icalendar",
|
||||
"vcard",
|
||||
"jscalendar",
|
||||
"jscontact",
|
||||
"parser",
|
||||
]
|
||||
categories = [
|
||||
"date-and-time",
|
||||
"email",
|
||||
"parser-implementations",
|
||||
]
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/stalwartlabs/calcard"
|
||||
resolver = "2"
|
||||
|
||||
[features]
|
||||
default = ["jmap"]
|
||||
jmap = [
|
||||
"jmap-tools",
|
||||
"uuid",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
rkyv = ["dep:rkyv"]
|
||||
serde = [
|
||||
"dep:serde",
|
||||
"chrono/serde",
|
||||
]
|
||||
wasm = ["hashify/force-32bit"]
|
||||
|
||||
[lib]
|
||||
name = "calcard"
|
||||
path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies.ahash]
|
||||
version = "0.8.11"
|
||||
|
||||
[dependencies.chrono]
|
||||
version = "0.4.40"
|
||||
|
||||
[dependencies.chrono-tz]
|
||||
version = "0.10.3"
|
||||
|
||||
[dependencies.hashify]
|
||||
version = "0.2.7"
|
||||
|
||||
[dependencies.jmap-tools]
|
||||
version = "0.1"
|
||||
optional = true
|
||||
|
||||
[dependencies.mail-builder]
|
||||
version = "1.0.0"
|
||||
default-features = false
|
||||
|
||||
[dependencies.mail-parser]
|
||||
version = "0.11"
|
||||
|
||||
[dependencies.rkyv]
|
||||
version = "0.8"
|
||||
optional = true
|
||||
|
||||
[dependencies.serde]
|
||||
version = "1.0"
|
||||
features = ["derive"]
|
||||
optional = true
|
||||
|
||||
[dependencies.serde_json]
|
||||
version = "1.0"
|
||||
optional = true
|
||||
|
||||
[dependencies.uuid]
|
||||
version = "1.26"
|
||||
features = ["v5"]
|
||||
optional = true
|
||||
default-features = false
|
||||
|
||||
[dev-dependencies.chrono]
|
||||
version = "0.4"
|
||||
features = ["serde"]
|
||||
|
||||
[dev-dependencies.rkyv]
|
||||
version = "0.8"
|
||||
|
||||
[dev-dependencies.serde]
|
||||
version = "1.0"
|
||||
features = ["derive"]
|
||||
|
||||
[dev-dependencies.serde_json]
|
||||
version = "1.0"
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
Vendored
+224
@@ -0,0 +1,224 @@
|
||||
# calcard
|
||||
|
||||
[](https://crates.io/crates/calcard)
|
||||
[](https://github.com/stalwartlabs/calcard/actions/workflows/rust.yml)
|
||||
[](https://docs.rs/calcard)
|
||||
[](http://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
`calcard` is a Rust crate for parsing, generating, and converting calendaring and contact data across multiple formats. It supports iCalendar and vCard, making it easy to work with `.ics` and `.vcf` files. It also fully handles JSCalendar and JSContact, the emerging standards commonly used in JMAP-based systems. In addition to parsing and serializing these formats, `calcard` provides seamless conversion between iCalendar and JSCalendar, as well as between vCard and JSContact, enabling smooth interoperability between traditional and modern calendaring and contact protocols.
|
||||
|
||||
In general, this library abides by the Postel's law or [Robustness Principle](https://en.wikipedia.org/wiki/Robustness_principle) which states that an implementation must be conservative in its sending behavior and liberal in its receiving behavior. This means that `calcard` will make a best effort to parse non-conformant iCal/vCard/JSCalendar/JSContact objects as long as these do not deviate too much from the standard.
|
||||
|
||||
## Features
|
||||
|
||||
Comprehensive features for working with calendaring and contact data, including:
|
||||
|
||||
- **iCalendar** support: Parsing and generating iCalendar data.
|
||||
- **vCard** support: Parsing and generating vCard data.
|
||||
- **JSCalendar** support: Parsing and generating JSCalendar data.
|
||||
- **JSContact** support: Parsing and generating JSContact data.
|
||||
- Seamless conversion between **iCalendar** and **JSCalendar**.
|
||||
- Seamless conversion between **vCard** and **JSContact**.
|
||||
- **Recurrence rules expansion**: Accurately computes and enumerates repeating events based on iCalendar and JSCalendar RRULEs.
|
||||
- **IANA timezone detection**: Automatically resolves and handles custom and proprietary timezones.
|
||||
|
||||
## Usage
|
||||
|
||||
### Parsing an iCalendar and/or vCard stream
|
||||
|
||||
You can parse vCard and iCalendar files using the stream-based parser:
|
||||
|
||||
```rust
|
||||
let input = "BEGIN:VCARD\nVERSION:3.0\nFN:John Doe\nEND:VCARD\n";
|
||||
let mut parser = Parser::new(&input);
|
||||
|
||||
loop {
|
||||
match parser.entry() {
|
||||
Entry::VCard(vcard) => println!("Parsed VCard: {:?}", vcard),
|
||||
Entry::ICalendar(ical) => println!("Parsed ICalendar: {:?}", ical),
|
||||
Entry::InvalidLine(line) => eprintln!("Invalid line found: {}", line),
|
||||
Entry::UnexpectedComponentEnd { expected, found } => {
|
||||
eprintln!("Unexpected end: expected {:?}, found {:?}", expected, found);
|
||||
},
|
||||
Entry::UnterminatedComponent(component) => {
|
||||
eprintln!("Unterminated component: {:?}", component);
|
||||
},
|
||||
Entry::Eof => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Parsing iCalendar
|
||||
|
||||
You can parse a single iCalendar using the `ICalendar::parse` method:
|
||||
|
||||
```rust
|
||||
let input = "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n";
|
||||
let ical = ICalendar::parse(&input);
|
||||
|
||||
println!("Parsed ICalendar: {:?}", ical);
|
||||
```
|
||||
|
||||
### Parsing vCard
|
||||
|
||||
You can parse a single vCard using the `VCard::parse` method:
|
||||
|
||||
```rust
|
||||
let input = "BEGIN:VCARD\nVERSION:3.0\nFN:John Doe\nEND:VCARD\n";
|
||||
let vcard = VCard::parse(&input);
|
||||
|
||||
println!("Parsed VCard: {:?}", vcard);
|
||||
```
|
||||
### Parsing JSCalendar
|
||||
|
||||
You can parse a JSCalendar JSON string using the `JSCalendar::parse` method:
|
||||
|
||||
```rust
|
||||
let input = r#"{
|
||||
"@type": "Group",
|
||||
"uid": "bf0ac22b-4989-4caf-9ebd-54301b4ee51a",
|
||||
"updated": "2020-01-15T18:00:00Z",
|
||||
"title": "A simple group",
|
||||
"entries": [{
|
||||
"@type": "Event",
|
||||
"uid": "a8df6573-0474-496d-8496-033ad45d7fea",
|
||||
"updated": "2020-01-02T18:23:04Z",
|
||||
"title": "Some event",
|
||||
"start": "2020-01-15T13:00:00",
|
||||
"timeZone": "America/New_York",
|
||||
"duration": "PT1H"
|
||||
},
|
||||
{
|
||||
"@type": "Task",
|
||||
"uid": "2a358cee-6489-4f14-a57f-c104db4dc2f2",
|
||||
"updated": "2020-01-09T14:32:01Z",
|
||||
"title": "Do something"
|
||||
}]
|
||||
}"#;
|
||||
|
||||
let jscalendar = JSCalendar::<String, String>::parse(input).unwrap();
|
||||
println!("Parsed JSCalendar: {}", jscalendar.to_string_pretty());
|
||||
```
|
||||
|
||||
### Parsing JSContact
|
||||
|
||||
You can parse a JSContact JSON string using the `JSContact::parse` method:
|
||||
|
||||
```rust
|
||||
let input = r#"{
|
||||
"@type": "Card",
|
||||
"version": "1.0",
|
||||
"uid": "22B2C7DF-9120-4969-8460-05956FE6B065",
|
||||
"kind": "individual",
|
||||
"name": {
|
||||
"components": [
|
||||
{ "kind": "given", "value": "John" },
|
||||
{ "kind": "surname", "value": "Doe" }
|
||||
],
|
||||
"isOrdered": true
|
||||
}
|
||||
}"#;
|
||||
|
||||
let jscontact = JSContact::<String, String>::parse(input).unwrap();
|
||||
println!("Parsed JSContact: {}", jscontact.to_string_pretty());
|
||||
```
|
||||
|
||||
### Converting to/from iCalendar and JSCalendar
|
||||
|
||||
To convert a JSCalendar to an iCalendar, use the `JSCalendar::into_icalendar` method:
|
||||
|
||||
```rust
|
||||
let ical = JSCalendar::<String, String>::parse(input).unwrap().into_icalendar().unwrap();
|
||||
```
|
||||
|
||||
To convert an iCalendar to a JSCalendar, use the `ICalendar::into_jscalendar` method:
|
||||
|
||||
```rust
|
||||
let jscalendar = ICalendar::parse(&input).unwrap().into_jscalendar::<String, String>().unwrap();
|
||||
```
|
||||
|
||||
### Converting to/from vCard and JSContact
|
||||
|
||||
To convert a JSContact to a vCard, use the `JSContact::into_vcard` method:
|
||||
|
||||
```rust
|
||||
let vcard = JSContact::<String, String>::parse(input).unwrap().into_vcard().unwrap();
|
||||
```
|
||||
|
||||
To convert a vCard to a JSContact, use the `VCard::into_jscontact` method:
|
||||
|
||||
```rust
|
||||
let jscontact = VCard::parse(&input).unwrap().into_jscontact::<String, String>().unwrap();
|
||||
```
|
||||
|
||||
### Generating iCalendar and vCard
|
||||
|
||||
To generate a vCard or iCalendar, simply call `.to_string()` on the parsed object:
|
||||
|
||||
```rust
|
||||
let vcard_string = vcard.to_string();
|
||||
println!("Generated vCard:\n{}", vcard_string);
|
||||
|
||||
let ical_string = ical.to_string();
|
||||
println!("Generated ICalendar:\n{}", ical_string);
|
||||
```
|
||||
|
||||
*Note: Documentation for creating VCard and ICalendar objects is coming soon.*
|
||||
|
||||
### Generating JSCalendar and JSContact
|
||||
|
||||
To generate a JSCalendar or JSContact, simply call `.to_string()` on the parsed object or `.to_string_pretty()` for a more human-readable format:
|
||||
|
||||
```rust
|
||||
let jscalendar_string = jscalendar.to_string();
|
||||
println!("Generated JSCalendar:\n{}", jscalendar_string);
|
||||
|
||||
let jscontact_string = jscontact.to_string();
|
||||
println!("Generated JSContact:\n{}", jscontact_string);
|
||||
```
|
||||
|
||||
*Note: Documentation for creating JSCalendar and JSContact objects is coming soon.*
|
||||
|
||||
|
||||
## Testing and Fuzzing
|
||||
|
||||
To run the testsuite:
|
||||
|
||||
```bash
|
||||
$ cargo test --all-features
|
||||
```
|
||||
|
||||
or, to run the testsuite with MIRI:
|
||||
|
||||
```bash
|
||||
$ cargo +nightly miri test --all-features
|
||||
```
|
||||
|
||||
To fuzz the library with `cargo-fuzz`:
|
||||
|
||||
```bash
|
||||
$ cargo +nightly fuzz run fuzz_all
|
||||
$ cargo +nightly fuzz run fuzz_random
|
||||
$ cargo +nightly fuzz run fuzz_structured
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of
|
||||
|
||||
* Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
|
||||
* MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
|
||||
|
||||
at your option.
|
||||
|
||||
## Funding
|
||||
|
||||
Part of the development of this library was funded through the [NGI Zero Core](https://nlnet.nl/NGI0/), a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 101092990.
|
||||
|
||||
If you find this library useful you can help by [becoming a sponsor](https://opencollective.com/stalwart). Thank you!
|
||||
|
||||
## Copyright
|
||||
|
||||
Copyright (C) 2020, Stalwart Labs LLC
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
# calcard, vendored
|
||||
|
||||
The published [calcard](https://crates.io/crates/calcard) **0.3.14**, taken
|
||||
from crates.io (`.crate` sha256
|
||||
`c601473ec15a875626bce73db1a1f0fc81e9c949ca25f1463b714947c0a70a1f`, the
|
||||
checksum the server's `Cargo.lock` had for it) under Apache-2.0 OR MIT.
|
||||
INBUXA takes it under MIT. The root `Cargo.toml` patches it in with
|
||||
`[patch.crates-io]`.
|
||||
|
||||
Left out: the top-level `resources/` (26 MB of iCalendar, vCard,
|
||||
JSCalendar and JSContact samples) and `.github/`, which the server never
|
||||
builds, and `Cargo.lock`, `Cargo.toml.orig`, `.cargo_vcs_info.json` and
|
||||
`.cargo-ok`. The crate's own tests that read `resources/` (`expand_rrule`, `parse_ical`, `parse_vcard`,
|
||||
`convert_jscalendar`, `convert_jscontact` and `remove_component_ids`) can't
|
||||
run here; the crate's other tests run from this directory with:
|
||||
|
||||
cargo test --offline --target-dir ../../target/calcard-tests -- \
|
||||
--skip expand_rrule --skip parse_ical --skip parse_vcard \
|
||||
--skip convert_jscalendar --skip convert_jscontact \
|
||||
--skip remove_component_ids
|
||||
|
||||
## Changes
|
||||
|
||||
Every changed file carries a Coffey Labs notice in its header.
|
||||
|
||||
- `.gitignore`: `VENDORED.md` is tracked (upstream ignores every `*.md`
|
||||
but the changelog and README).
|
||||
- `src/datecalc/mod.rs`, `src/datecalc/rrule_iter.rs`,
|
||||
`src/datecalc/counter_date.rs`, `src/icalendar/dates.rs`: one
|
||||
`expand_dates` call has a single work budget for all its recurrence rules
|
||||
(`MAX_EXPANSION_WORK`, 1,000,000 units, about 50 ms in a release build).
|
||||
Upstream only stops a rule after 100,000 passes that find nothing, counted
|
||||
afresh for every instance and every rule, so a calendar of a few thousand
|
||||
rules that never match (`FREQ=MONTHLY;BYMONTH=2;BYMONTHDAY=31`), or one
|
||||
sparse rule, took seconds to minutes. A pass over a period costs one unit,
|
||||
a pass that finds nothing one per 16 days it scanned, each candidate a
|
||||
pass looks at without producing it one, and each time skipped while
|
||||
looking for the next `BYHOUR`/`BYMINUTE`/`BYSECOND` match one
|
||||
(`DateTimeIter::increment_counted` counts those). When the budget runs
|
||||
out, expansion stops: the instances found so far are kept, as when the
|
||||
instance limit is reached, later rules aren't expanded, and the result
|
||||
carries a `CalendarErrorType::ExpansionBudgetExhausted` error for the
|
||||
component it stopped at. Rules are no longer started once the instance
|
||||
limit is reached (they couldn't add anything). The `budget_tests` in
|
||||
`dates.rs` cover it, and `rule_expansion_shares_one_budget` in
|
||||
`crates/groupware` fails to build if the server stops using this copy.
|
||||
|
||||
## Updating
|
||||
|
||||
When the server's `Cargo.lock` moves calcard to a new version, Cargo stops
|
||||
using this copy and only warns that the patch went unused, so check
|
||||
`Cargo.lock` has no `calcard` entry with a `source` line. Re-vendor the new
|
||||
version from `~/.cargo/registry/src/*/calcard-*`, dropping the top-level
|
||||
`resources/` and `.github/`, `Cargo.lock`, `Cargo.toml.orig`,
|
||||
`.cargo_vcs_info.json` and `.cargo-ok` (`rsync --exclude /resources
|
||||
--exclude /.github ...`), repeat the changes above, and update the version
|
||||
here and in the root `Cargo.toml` comment.
|
||||
Vendored
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::common::{IanaString, IanaType};
|
||||
|
||||
impl<I, O> IanaType<I, O> {
|
||||
pub fn iana(&self) -> Option<&I> {
|
||||
match self {
|
||||
IanaType::Iana(iana) => Some(iana),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn other(&self) -> Option<&O> {
|
||||
match self {
|
||||
IanaType::Other(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_iana(self) -> Option<I> {
|
||||
match self {
|
||||
IanaType::Iana(iana) => Some(iana),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_iana(&self) -> bool {
|
||||
matches!(self, IanaType::Iana(_))
|
||||
}
|
||||
|
||||
pub fn is_iana_and(&self, f: impl FnOnce(&I) -> bool) -> bool {
|
||||
match &self {
|
||||
IanaType::Iana(x) => f(x),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<I, O> AsRef<str> for IanaType<I, O>
|
||||
where
|
||||
I: IanaString,
|
||||
O: AsRef<str>,
|
||||
{
|
||||
fn as_ref(&self) -> &str {
|
||||
match self {
|
||||
IanaType::Iana(iana) => iana.as_str(),
|
||||
IanaType::Other(s) => s.as_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<I> IanaType<I, String>
|
||||
where
|
||||
I: IanaString,
|
||||
{
|
||||
pub fn into_string(self) -> Cow<'static, str> {
|
||||
match self {
|
||||
IanaType::Iana(iana) => Cow::Borrowed(iana.as_str()),
|
||||
IanaType::Other(s) => Cow::Owned(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<I> From<I> for IanaType<I, String>
|
||||
where
|
||||
I: IanaString,
|
||||
{
|
||||
fn from(iana: I) -> Self {
|
||||
IanaType::Iana(iana)
|
||||
}
|
||||
}
|
||||
|
||||
impl<I> From<String> for IanaType<I, String> {
|
||||
fn from(s: String) -> Self {
|
||||
IanaType::Other(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<I, O> Default for IanaType<I, O>
|
||||
where
|
||||
I: IanaString
|
||||
+ std::fmt::Debug
|
||||
+ Clone
|
||||
+ PartialEq
|
||||
+ Eq
|
||||
+ PartialOrd
|
||||
+ Ord
|
||||
+ std::hash::Hash
|
||||
+ Default,
|
||||
O: std::fmt::Debug + Clone + PartialEq + Eq + PartialOrd + Ord + std::hash::Hash,
|
||||
{
|
||||
fn default() -> Self {
|
||||
IanaType::Iana(I::default())
|
||||
}
|
||||
}
|
||||
Vendored
+263
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use chrono::{FixedOffset, NaiveDate, NaiveDateTime};
|
||||
use mail_parser::DateTime;
|
||||
|
||||
pub mod iana;
|
||||
pub mod parser;
|
||||
pub mod timezone;
|
||||
pub mod tokenizer;
|
||||
pub mod types;
|
||||
pub mod writer;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum IanaType<I, O> {
|
||||
Iana(I),
|
||||
Other(O),
|
||||
}
|
||||
|
||||
pub trait IanaString {
|
||||
fn as_str(&self) -> &'static str;
|
||||
}
|
||||
|
||||
pub trait IanaParse: Sized {
|
||||
fn parse(value: &[u8]) -> Option<Self>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub struct PartialDateTime {
|
||||
pub year: Option<u16>,
|
||||
pub month: Option<u8>,
|
||||
pub day: Option<u8>,
|
||||
pub hour: Option<u8>,
|
||||
pub minute: Option<u8>,
|
||||
pub second: Option<u8>,
|
||||
pub tz_hour: Option<u8>,
|
||||
pub tz_minute: Option<u8>,
|
||||
pub tz_minus: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum CalendarScale {
|
||||
#[default]
|
||||
Gregorian,
|
||||
Chinese,
|
||||
IslamicCivil,
|
||||
Hebrew,
|
||||
Ethiopic,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Encoding {
|
||||
QuotedPrintable,
|
||||
Base64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub struct Data {
|
||||
pub content_type: Option<String>,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DateTimeResult {
|
||||
pub date_time: NaiveDateTime,
|
||||
pub offset: Option<FixedOffset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum LinkRelation {
|
||||
About,
|
||||
Acl,
|
||||
Alternate,
|
||||
Amphtml,
|
||||
ApiCatalog,
|
||||
Appendix,
|
||||
AppleTouchIcon,
|
||||
AppleTouchStartupImage,
|
||||
Archives,
|
||||
Author,
|
||||
BlockedBy,
|
||||
Bookmark,
|
||||
C2paManifest,
|
||||
Canonical,
|
||||
Chapter,
|
||||
CiteAs,
|
||||
Collection,
|
||||
CompressionDictionary,
|
||||
Contents,
|
||||
Convertedfrom,
|
||||
Copyright,
|
||||
CreateForm,
|
||||
Current,
|
||||
Deprecation,
|
||||
Describedby,
|
||||
Describes,
|
||||
Disclosure,
|
||||
DnsPrefetch,
|
||||
Duplicate,
|
||||
Edit,
|
||||
EditForm,
|
||||
EditMedia,
|
||||
Enclosure,
|
||||
External,
|
||||
First,
|
||||
Geofeed,
|
||||
Glossary,
|
||||
Help,
|
||||
Hosts,
|
||||
Hub,
|
||||
IceServer,
|
||||
Icon,
|
||||
Index,
|
||||
Intervalafter,
|
||||
Intervalbefore,
|
||||
Intervalcontains,
|
||||
Intervaldisjoint,
|
||||
Intervalduring,
|
||||
Intervalequals,
|
||||
Intervalfinishedby,
|
||||
Intervalfinishes,
|
||||
Intervalin,
|
||||
Intervalmeets,
|
||||
Intervalmetby,
|
||||
Intervaloverlappedby,
|
||||
Intervaloverlaps,
|
||||
Intervalstartedby,
|
||||
Intervalstarts,
|
||||
Item,
|
||||
Last,
|
||||
LatestVersion,
|
||||
License,
|
||||
Linkset,
|
||||
Lrdd,
|
||||
Manifest,
|
||||
MaskIcon,
|
||||
Me,
|
||||
MediaFeed,
|
||||
Memento,
|
||||
Micropub,
|
||||
Modulepreload,
|
||||
Monitor,
|
||||
MonitorGroup,
|
||||
Next,
|
||||
NextArchive,
|
||||
Nofollow,
|
||||
Noopener,
|
||||
Noreferrer,
|
||||
Opener,
|
||||
Openid2LocalId,
|
||||
Openid2Provider,
|
||||
Original,
|
||||
P3pv1,
|
||||
Payment,
|
||||
Pingback,
|
||||
Preconnect,
|
||||
PredecessorVersion,
|
||||
Prefetch,
|
||||
Preload,
|
||||
Prerender,
|
||||
Prev,
|
||||
Preview,
|
||||
Previous,
|
||||
PrevArchive,
|
||||
PrivacyPolicy,
|
||||
Profile,
|
||||
Publication,
|
||||
RdapActive,
|
||||
RdapBottom,
|
||||
RdapDown,
|
||||
RdapTop,
|
||||
RdapUp,
|
||||
Related,
|
||||
Restconf,
|
||||
Replies,
|
||||
Ruleinput,
|
||||
Search,
|
||||
Section,
|
||||
Self_,
|
||||
Service,
|
||||
ServiceDesc,
|
||||
ServiceDoc,
|
||||
ServiceMeta,
|
||||
SipTrunkingCapability,
|
||||
Sponsored,
|
||||
Start,
|
||||
Status,
|
||||
Stylesheet,
|
||||
Subsection,
|
||||
SuccessorVersion,
|
||||
Sunset,
|
||||
Tag,
|
||||
TermsOfService,
|
||||
Timegate,
|
||||
Timemap,
|
||||
Type,
|
||||
Ugc,
|
||||
Up,
|
||||
VersionHistory,
|
||||
Via,
|
||||
Webmention,
|
||||
WorkingCopy,
|
||||
WorkingCopyOf,
|
||||
}
|
||||
|
||||
pub(crate) enum IdReference<T> {
|
||||
Value(T),
|
||||
Reference(String),
|
||||
Error,
|
||||
}
|
||||
|
||||
impl<T: std::str::FromStr> IdReference<T> {
|
||||
pub fn parse(s: &str) -> Self {
|
||||
if let Some(reference) = s.strip_prefix('#') {
|
||||
IdReference::Reference(reference.to_string())
|
||||
} else {
|
||||
T::from_str(s)
|
||||
.map(IdReference::Value)
|
||||
.unwrap_or(IdReference::Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+606
@@ -0,0 +1,606 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::{Data, PartialDateTime, tokenizer::Token};
|
||||
use crate::{
|
||||
Parser,
|
||||
common::{IanaParse, IanaString, IanaType},
|
||||
icalendar::Uri,
|
||||
};
|
||||
use mail_parser::{
|
||||
DateTime,
|
||||
decoders::{base64::base64_decode, hex::decode_hex},
|
||||
};
|
||||
use std::{borrow::Cow, iter::Peekable, slice::Iter, str::FromStr};
|
||||
|
||||
impl<'x> Parser<'x> {
|
||||
pub(crate) fn raw_token(&mut self) -> Option<Cow<'x, str>> {
|
||||
self.token_buf
|
||||
.first()
|
||||
.and_then(|first| {
|
||||
self.input
|
||||
.get(first.start..=self.token_buf.last().unwrap().end)
|
||||
})
|
||||
.and_then(|v| std::str::from_utf8(v).ok())
|
||||
.map(unfold)
|
||||
}
|
||||
|
||||
pub(crate) fn buf_parse_many<T: From<Token<'x>>>(&mut self) -> Vec<T> {
|
||||
self.token_buf.drain(..).map(T::from).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn buf_parse_one<T: From<Token<'x>>>(&mut self) -> Option<T> {
|
||||
let result = self.token_buf.pop().map(T::from);
|
||||
self.token_buf.clear();
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl Token<'_> {
|
||||
pub(crate) fn into_uri_bytes(self) -> std::result::Result<Data, String> {
|
||||
match Data::try_parse(self.text.as_ref()) {
|
||||
Some(data) => Ok(data),
|
||||
None => Err(self.into_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn into_timestamp(
|
||||
self,
|
||||
require_time: bool,
|
||||
) -> std::result::Result<PartialDateTime, String> {
|
||||
let mut dt = PartialDateTime::default();
|
||||
if dt.parse_timestamp(&mut self.text.iter().peekable(), require_time) {
|
||||
Ok(dt)
|
||||
} else {
|
||||
Err(self.into_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn into_offset(self) -> std::result::Result<PartialDateTime, String> {
|
||||
let mut dt = PartialDateTime::default();
|
||||
dt.parse_zone(&mut self.text.iter().peekable());
|
||||
if !dt.is_null() {
|
||||
Ok(dt)
|
||||
} else {
|
||||
Err(self.into_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn into_float(self) -> std::result::Result<f64, String> {
|
||||
if let Ok(text) = std::str::from_utf8(self.text.as_ref())
|
||||
&& let Ok(float) = text.parse::<f64>()
|
||||
{
|
||||
return Ok(float);
|
||||
}
|
||||
|
||||
Err(self.into_string())
|
||||
}
|
||||
|
||||
pub(crate) fn into_integer(self) -> std::result::Result<i64, String> {
|
||||
if let Ok(text) = std::str::from_utf8(self.text.as_ref())
|
||||
&& let Ok(float) = text.parse::<i64>()
|
||||
{
|
||||
return Ok(float);
|
||||
}
|
||||
|
||||
Err(self.into_string())
|
||||
}
|
||||
|
||||
pub(crate) fn into_boolean(self) -> bool {
|
||||
self.text.as_ref().eq_ignore_ascii_case(b"true")
|
||||
}
|
||||
}
|
||||
|
||||
impl Data {
|
||||
pub fn try_parse(text: &[u8]) -> Option<Self> {
|
||||
if text
|
||||
.as_ref()
|
||||
.get(0..5)
|
||||
.unwrap_or_default()
|
||||
.eq_ignore_ascii_case(b"data:")
|
||||
{
|
||||
let mut bin = Data::default();
|
||||
let text = text.as_ref().get(5..).unwrap_or_default();
|
||||
let mut offset_start = 0;
|
||||
let mut is_base64 = false;
|
||||
|
||||
for (idx, ch) in text.iter().enumerate() {
|
||||
match ch {
|
||||
b';' => {
|
||||
if idx > offset_start && bin.content_type.is_none() {
|
||||
bin.content_type = Some(
|
||||
std::str::from_utf8(&text[offset_start..idx])
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
offset_start = idx + 1;
|
||||
}
|
||||
b',' => {
|
||||
if idx != offset_start {
|
||||
static B64_LEN: usize = "base64".len();
|
||||
|
||||
let text = text.get(offset_start..idx).unwrap_or_default();
|
||||
if text.len() == B64_LEN && text.eq_ignore_ascii_case(b"base64")
|
||||
|| text.len() == B64_LEN + 1
|
||||
&& text[..B64_LEN].eq_ignore_ascii_case(b"base64")
|
||||
{
|
||||
is_base64 = true;
|
||||
} else if bin.content_type.is_none() {
|
||||
bin.content_type =
|
||||
Some(std::str::from_utf8(text).unwrap_or_default().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
offset_start = idx + 1;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let text = text.get(offset_start..).unwrap_or_default();
|
||||
if !text.is_empty() {
|
||||
if is_base64 {
|
||||
if let Some(bytes) = base64_decode(text) {
|
||||
bin.data = bytes;
|
||||
return Some(bin);
|
||||
}
|
||||
} else {
|
||||
let (success, bytes) = decode_hex(text);
|
||||
if success {
|
||||
bin.data = bytes;
|
||||
return Some(bin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialDateTime {
|
||||
pub fn parse_timestamp(
|
||||
&mut self,
|
||||
iter: &mut Peekable<Iter<'_, u8>>,
|
||||
require_time: bool,
|
||||
) -> bool {
|
||||
let mut idx = 0;
|
||||
for ch in iter {
|
||||
match ch {
|
||||
b'0'..=b'9' => {
|
||||
let value = match idx {
|
||||
0..=3 => {
|
||||
if let Some(value) = &mut self.year {
|
||||
*value =
|
||||
value.saturating_mul(10).saturating_add((ch - b'0') as u16);
|
||||
} else {
|
||||
self.year = Some((ch - b'0') as u16);
|
||||
}
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
4..=5 => &mut self.month,
|
||||
6..=7 => &mut self.day,
|
||||
9..=10 => &mut self.hour,
|
||||
11..=12 => &mut self.minute,
|
||||
13..=14 => &mut self.second,
|
||||
16..=17 => &mut self.tz_hour,
|
||||
18..=19 => &mut self.tz_minute,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
if let Some(value) = value {
|
||||
*value = value.saturating_mul(10).saturating_add(ch - b'0');
|
||||
} else {
|
||||
*value = Some(ch - b'0');
|
||||
}
|
||||
}
|
||||
b'T' | b't' if idx == 8 => {}
|
||||
b'+' if idx == 15 => {}
|
||||
b'Z' | b'z' if idx == 15 => {
|
||||
self.tz_hour = Some(0);
|
||||
self.tz_minute = Some(0);
|
||||
}
|
||||
b'-' if idx == 15 => {
|
||||
self.tz_minus = true;
|
||||
}
|
||||
b' ' | b'\t' | b'\r' | b'\n' => {
|
||||
continue;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
if self.hour.is_some() {
|
||||
self.minute.get_or_insert(0);
|
||||
self.second.get_or_insert(0);
|
||||
}
|
||||
|
||||
self.has_date() && (!require_time || self.has_time())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_zone(&mut self, iter: &mut Peekable<Iter<'_, u8>>) -> bool {
|
||||
self.tz_minus = match iter.peek() {
|
||||
Some(b'-') => true,
|
||||
Some(b'+') => false,
|
||||
Some(b'Z') | Some(b'z') => {
|
||||
self.tz_hour = Some(0);
|
||||
self.tz_minute = Some(0);
|
||||
iter.next();
|
||||
return true;
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
iter.next();
|
||||
let mut idx = 0;
|
||||
for ch in iter {
|
||||
match ch {
|
||||
b'0'..=b'9' => {
|
||||
idx += 1;
|
||||
let value = match idx {
|
||||
1 | 2 => &mut self.tz_hour,
|
||||
3 | 4 => &mut self.tz_minute,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
if let Some(value) = value {
|
||||
*value = value.saturating_mul(10).saturating_add(ch - b'0');
|
||||
} else {
|
||||
*value = Some(ch - b'0');
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !ch.is_ascii_whitespace() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.tz_hour.is_some()
|
||||
}
|
||||
|
||||
pub fn is_null(&self) -> bool {
|
||||
self.year.is_none()
|
||||
&& self.month.is_none()
|
||||
&& self.day.is_none()
|
||||
&& self.hour.is_none()
|
||||
&& self.minute.is_none()
|
||||
&& self.second.is_none()
|
||||
&& self.tz_hour.is_none()
|
||||
&& self.tz_minute.is_none()
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn has_date(&self) -> bool {
|
||||
self.year.is_some() && self.month.is_some() && self.day.is_some()
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn has_time(&self) -> bool {
|
||||
self.hour.is_some() && self.minute.is_some()
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn has_zone(&self) -> bool {
|
||||
self.tz_hour.is_some()
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn has_date_and_time(&self) -> bool {
|
||||
self.has_date() && self.has_time()
|
||||
}
|
||||
|
||||
fn to_datetime(&self) -> Option<DateTime> {
|
||||
if self.has_date() && self.has_time() {
|
||||
DateTime {
|
||||
year: self.year.unwrap(),
|
||||
month: self.month.unwrap(),
|
||||
day: self.day.unwrap(),
|
||||
hour: self.hour.unwrap(),
|
||||
minute: self.minute.unwrap(),
|
||||
second: self.second.unwrap_or_default(),
|
||||
tz_before_gmt: self.tz_minus,
|
||||
tz_hour: self.tz_hour.unwrap_or_default(),
|
||||
tz_minute: self.tz_minute.unwrap_or_default(),
|
||||
}
|
||||
.into()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_rfc3339(&self) -> Option<String> {
|
||||
self.to_datetime().map(|dt| dt.to_rfc3339())
|
||||
}
|
||||
|
||||
pub fn to_timestamp(&self) -> Option<i64> {
|
||||
self.to_datetime().map(|dt| dt.to_timestamp())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_digits(
|
||||
iter: &mut Peekable<Iter<'_, u8>>,
|
||||
target: &mut Option<u16>,
|
||||
num: usize,
|
||||
nullable: bool,
|
||||
) -> bool {
|
||||
let mut idx = 0;
|
||||
while let Some(ch) = iter.peek() {
|
||||
match ch {
|
||||
b'0'..=b'9' => {
|
||||
let ch = (*ch - b'0') as u16;
|
||||
idx += 1;
|
||||
iter.next();
|
||||
|
||||
if let Some(target) = target {
|
||||
*target = target.saturating_mul(10).saturating_add(ch);
|
||||
|
||||
if idx == num {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
*target = Some(ch);
|
||||
}
|
||||
}
|
||||
b'-' if nullable => {
|
||||
idx += 1;
|
||||
iter.next();
|
||||
if idx == num / 2 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !ch.is_ascii_whitespace() {
|
||||
return false;
|
||||
} else {
|
||||
iter.next();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn parse_small_digits(
|
||||
iter: &mut Peekable<Iter<'_, u8>>,
|
||||
target: &mut Option<u8>,
|
||||
num: usize,
|
||||
nullable: bool,
|
||||
) -> bool {
|
||||
let mut idx = 0;
|
||||
while let Some(ch) = iter.peek() {
|
||||
match ch {
|
||||
b'0'..=b'9' => {
|
||||
let ch = *ch - b'0';
|
||||
idx += 1;
|
||||
iter.next();
|
||||
|
||||
if let Some(target) = target {
|
||||
*target = target.saturating_mul(10).saturating_add(ch);
|
||||
|
||||
if idx == num {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
*target = Some(ch);
|
||||
}
|
||||
}
|
||||
b'-' if nullable => {
|
||||
idx += 1;
|
||||
iter.next();
|
||||
if idx == num / 2 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !ch.is_ascii_whitespace() {
|
||||
return false;
|
||||
} else {
|
||||
iter.next();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn unfold(text: &str) -> Cow<'_, str> {
|
||||
if !text.as_bytes().contains(&b'\n') {
|
||||
return Cow::Borrowed(text);
|
||||
}
|
||||
|
||||
let mut unfolded = String::with_capacity(text.len());
|
||||
|
||||
for (pos, chunk) in text.split('\n').enumerate() {
|
||||
let chunk = if pos == 0 {
|
||||
chunk
|
||||
} else {
|
||||
chunk.strip_prefix([' ', '\t']).unwrap_or(chunk)
|
||||
};
|
||||
unfolded.push_str(chunk.strip_suffix('\r').unwrap_or(chunk));
|
||||
}
|
||||
|
||||
Cow::Owned(unfolded)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Timestamp(pub i64);
|
||||
|
||||
impl FromStr for Timestamp {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
let mut dt = PartialDateTime::default();
|
||||
dt.parse_timestamp(&mut s.as_bytes().iter().peekable(), true);
|
||||
dt.to_timestamp().map(Timestamp).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl IanaParse for Timestamp {
|
||||
fn parse(value: &[u8]) -> Option<Self> {
|
||||
let mut dt = PartialDateTime::default();
|
||||
dt.parse_timestamp(&mut value.iter().peekable(), true);
|
||||
dt.to_timestamp().map(Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Integer(pub i64);
|
||||
|
||||
impl IanaParse for Integer {
|
||||
fn parse(value: &[u8]) -> Option<Self> {
|
||||
let mut num: i64 = 0;
|
||||
let mut is_negative = false;
|
||||
|
||||
for (pos, ch) in value.iter().enumerate() {
|
||||
match ch {
|
||||
b'-' if pos == 0 => {
|
||||
is_negative = true;
|
||||
}
|
||||
b'+' if pos == 0 => {}
|
||||
b'0'..=b'9' => {
|
||||
num = num.saturating_mul(10).saturating_add((*ch - b'0') as i64);
|
||||
}
|
||||
_ => {
|
||||
if !ch.is_ascii_whitespace() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(Integer(if is_negative { -num } else { num }))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Boolean(pub bool);
|
||||
|
||||
impl IanaParse for Boolean {
|
||||
fn parse(value: &[u8]) -> Option<Self> {
|
||||
if value.eq_ignore_ascii_case(b"true") {
|
||||
Some(Boolean(true))
|
||||
} else if value.eq_ignore_ascii_case(b"false") {
|
||||
Some(Boolean(false))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<I> From<Token<'_>> for IanaType<I, String>
|
||||
where
|
||||
I: IanaParse,
|
||||
{
|
||||
fn from(value: Token<'_>) -> Self {
|
||||
I::parse(value.text.as_ref())
|
||||
.map(IanaType::Iana)
|
||||
.unwrap_or_else(|| IanaType::Other(value.into_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<I> From<Token<'_>> for IanaType<I, Uri>
|
||||
where
|
||||
I: IanaString
|
||||
+ IanaParse
|
||||
+ std::fmt::Debug
|
||||
+ Clone
|
||||
+ PartialEq
|
||||
+ Eq
|
||||
+ PartialOrd
|
||||
+ Ord
|
||||
+ std::hash::Hash,
|
||||
{
|
||||
fn from(value: Token<'_>) -> Self {
|
||||
I::parse(value.text.as_ref())
|
||||
.map(IanaType::Iana)
|
||||
.unwrap_or_else(|| {
|
||||
IanaType::Other(
|
||||
value
|
||||
.into_uri_bytes()
|
||||
.map(Uri::Data)
|
||||
.unwrap_or_else(Uri::Location),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use crate::common::tokenizer::StopChar;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_uri() {
|
||||
for (uri, expected) in [
|
||||
(
|
||||
concat!(
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAALCAQAAAADpb+\n",
|
||||
"tAAAAQklEQVQI122PQQ4AMAjCKv//Mzs4M0zmRYKkamEwWQVoRJogk4PuRoOoMC/EK8nYb+",
|
||||
"l08WGvSxKlNHO5kxnp/WXrAzsSERN1N6q5AAAAAElFTkSuQmCC"
|
||||
),
|
||||
Data {
|
||||
content_type: Some("image/png".to_string()),
|
||||
data: vec![
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 11,
|
||||
0, 0, 0, 11, 8, 4, 0, 0, 0, 3, 165, 191, 173, 0, 0, 0, 66, 73, 68, 65, 84,
|
||||
8, 215, 109, 143, 65, 14, 0, 48, 8, 194, 42, 255, 255, 51, 59, 56, 51, 76,
|
||||
230, 69, 130, 164, 106, 97, 48, 89, 5, 104, 68, 154, 32, 147, 131, 238, 70,
|
||||
131, 168, 48, 47, 196, 43, 201, 216, 111, 233, 116, 241, 97, 175, 75, 18,
|
||||
165, 52, 115, 185, 147, 25, 233, 253, 101, 235, 3, 59, 18, 17, 19, 117, 55,
|
||||
170, 185, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
"data:text/html,%3Cbody%3E%3Cp%3EToller%20%3Cstron",
|
||||
"g%20class%3D%22text-bold%22%3ETermin%3C%2Fstrong%3E%20f%C3%BCr%3C%2Fp",
|
||||
"%3E%3Cblockquote%3E%3Cp%3Emal%20%3Cem%20class%3D%22text-italic%22%3Ez",
|
||||
"u%22gucken%22%3C%2Fem%3E%3C%2Fp%3E%3C%2Fblockquote%3E%3Cp%3E%3Cu%20cl",
|
||||
"ass%3D%22text-underline%22%3Eund%3C%2Fu%3E%20%3Cdel%3Eso%3C%2Fdel%3E%",
|
||||
"3Cbr%2F%3E%3C%2Fp%3E%3C%2Fbody%3E"
|
||||
),
|
||||
Data {
|
||||
content_type: Some("text/html".to_string()),
|
||||
data: vec![
|
||||
60, 98, 111, 100, 121, 62, 60, 112, 62, 84, 111, 108, 108, 101, 114, 32,
|
||||
60, 115, 116, 114, 111, 110, 103, 32, 99, 108, 97, 115, 115, 61, 34, 116,
|
||||
101, 120, 116, 45, 98, 111, 108, 100, 34, 62, 84, 101, 114, 109, 105, 110,
|
||||
60, 47, 115, 116, 114, 111, 110, 103, 62, 32, 102, 195, 188, 114, 60, 47,
|
||||
112, 62, 60, 98, 108, 111, 99, 107, 113, 117, 111, 116, 101, 62, 60, 112,
|
||||
62, 109, 97, 108, 32, 60, 101, 109, 32, 99, 108, 97, 115, 115, 61, 34, 116,
|
||||
101, 120, 116, 45, 105, 116, 97, 108, 105, 99, 34, 62, 122, 117, 34, 103,
|
||||
117, 99, 107, 101, 110, 34, 60, 47, 101, 109, 62, 60, 47, 112, 62, 60, 47,
|
||||
98, 108, 111, 99, 107, 113, 117, 111, 116, 101, 62, 60, 112, 62, 60, 117,
|
||||
32, 99, 108, 97, 115, 115, 61, 34, 116, 101, 120, 116, 45, 117, 110, 100,
|
||||
101, 114, 108, 105, 110, 101, 34, 62, 117, 110, 100, 60, 47, 117, 62, 32,
|
||||
60, 100, 101, 108, 62, 115, 111, 60, 47, 100, 101, 108, 62, 60, 98, 114,
|
||||
47, 62, 60, 47, 112, 62, 60, 47, 98, 111, 100, 121, 62,
|
||||
],
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
Token {
|
||||
text: uri.as_bytes().into(),
|
||||
start: 0,
|
||||
end: 0,
|
||||
stop_char: StopChar::Lf
|
||||
}
|
||||
.into_uri_bytes(),
|
||||
Ok(expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2046
File diff suppressed because it is too large.
Load diff
+791
@@ -0,0 +1,791 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use crate::{Parser, common::IanaParse};
|
||||
use std::borrow::Cow;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Token<'x> {
|
||||
pub(crate) text: Cow<'x, [u8]>,
|
||||
pub(crate) start: usize,
|
||||
pub(crate) end: usize,
|
||||
pub(crate) stop_char: StopChar,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub(crate) enum StopChar {
|
||||
Colon,
|
||||
Semicolon,
|
||||
Comma,
|
||||
Equal,
|
||||
Dot,
|
||||
Lf,
|
||||
}
|
||||
|
||||
impl<'x> Parser<'x> {
|
||||
pub(crate) fn expect_iana_token(&mut self) {
|
||||
self.stop_colon = true;
|
||||
self.stop_semicolon = true;
|
||||
self.stop_comma = true;
|
||||
self.stop_equal = true;
|
||||
self.unfold_qp = false;
|
||||
self.unfold_b64 = false;
|
||||
self.unquote = true;
|
||||
self.stop_dot = false;
|
||||
self.skip_ws = true;
|
||||
self.strip_ctl = false;
|
||||
}
|
||||
|
||||
pub(crate) fn expect_single_value(&mut self) {
|
||||
self.stop_colon = false;
|
||||
self.stop_comma = false;
|
||||
self.stop_equal = false;
|
||||
self.stop_semicolon = false;
|
||||
self.unquote = false;
|
||||
self.unfold_qp = false;
|
||||
self.unfold_b64 = false;
|
||||
self.stop_dot = false;
|
||||
self.skip_ws = false;
|
||||
self.strip_ctl = false;
|
||||
}
|
||||
|
||||
pub(crate) fn expect_multi_value_comma(&mut self) {
|
||||
self.stop_colon = false;
|
||||
self.stop_comma = true;
|
||||
self.stop_equal = false;
|
||||
self.stop_semicolon = false;
|
||||
self.unquote = false;
|
||||
self.unfold_qp = false;
|
||||
self.unfold_b64 = false;
|
||||
self.stop_dot = false;
|
||||
self.skip_ws = true;
|
||||
self.strip_ctl = false;
|
||||
}
|
||||
|
||||
pub(crate) fn expect_multi_value_semicolon(&mut self) {
|
||||
self.stop_colon = false;
|
||||
self.stop_comma = false;
|
||||
self.stop_equal = false;
|
||||
self.stop_semicolon = true;
|
||||
self.unquote = false;
|
||||
self.unfold_qp = false;
|
||||
self.unfold_b64 = false;
|
||||
self.stop_dot = false;
|
||||
self.skip_ws = false;
|
||||
self.strip_ctl = false;
|
||||
}
|
||||
|
||||
pub(crate) fn expect_multi_value_semicolon_and_comma(&mut self) {
|
||||
self.stop_colon = false;
|
||||
self.stop_comma = true;
|
||||
self.stop_equal = false;
|
||||
self.stop_semicolon = true;
|
||||
self.unquote = false;
|
||||
self.unfold_qp = false;
|
||||
self.unfold_b64 = false;
|
||||
self.stop_dot = false;
|
||||
self.skip_ws = false;
|
||||
self.strip_ctl = false;
|
||||
}
|
||||
|
||||
pub(crate) fn expect_param_value(&mut self) {
|
||||
self.stop_colon = true;
|
||||
self.stop_semicolon = true;
|
||||
self.stop_comma = true;
|
||||
self.stop_equal = false;
|
||||
self.unfold_qp = false;
|
||||
self.unfold_b64 = false;
|
||||
self.unquote = true;
|
||||
self.stop_dot = false;
|
||||
self.skip_ws = true;
|
||||
self.strip_ctl = true;
|
||||
}
|
||||
|
||||
pub(crate) fn expect_rrule_value(&mut self) {
|
||||
self.stop_colon = true;
|
||||
self.stop_comma = true;
|
||||
self.stop_equal = true;
|
||||
self.stop_semicolon = true;
|
||||
self.unquote = false;
|
||||
self.unfold_qp = false;
|
||||
self.unfold_b64 = false;
|
||||
self.stop_dot = false;
|
||||
self.skip_ws = true;
|
||||
self.strip_ctl = false;
|
||||
}
|
||||
|
||||
fn try_unfold(&mut self) -> bool {
|
||||
if let Some((_, next)) = self.iter.peek()
|
||||
&& (**next == b' ' || **next == b'\t')
|
||||
{
|
||||
self.iter.next();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_base64_continuation(&self, idx: usize) -> bool {
|
||||
let mut has_data = false;
|
||||
|
||||
for ch in self.input.get(idx + 1..).unwrap_or_default() {
|
||||
match ch {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/' | b'=' => has_data = true,
|
||||
b'\r' => {}
|
||||
b'\n' => break,
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
|
||||
has_data
|
||||
}
|
||||
|
||||
pub(crate) fn token(&mut self) -> Option<Token<'x>> {
|
||||
let mut offset_start = usize::MAX;
|
||||
let mut offset_end = usize::MAX;
|
||||
let mut last_idx = 0;
|
||||
let mut in_quote = false;
|
||||
let stop_char;
|
||||
let mut buf: Vec<u8> = vec![];
|
||||
let mut skipped_ws = usize::MAX;
|
||||
|
||||
'outer: loop {
|
||||
let (idx, ch) = if let Some(next) = self.iter.next() {
|
||||
next
|
||||
} else if offset_start != usize::MAX {
|
||||
stop_char = StopChar::Lf;
|
||||
break;
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
last_idx = idx;
|
||||
|
||||
match ch {
|
||||
b' ' | b'\t' => {
|
||||
// Ignore leading and trailing whitespace (unless in a quoted string)
|
||||
if in_quote || !self.skip_ws || buf.last().is_some_and(|lch| lch != ch) {
|
||||
if offset_start == usize::MAX {
|
||||
offset_start = idx;
|
||||
}
|
||||
offset_end = idx;
|
||||
skipped_ws = usize::MAX;
|
||||
|
||||
if !buf.is_empty() {
|
||||
buf.push(*ch);
|
||||
}
|
||||
} else if skipped_ws == usize::MAX {
|
||||
skipped_ws = idx;
|
||||
}
|
||||
}
|
||||
b'\r' => {}
|
||||
b'\n' => {
|
||||
if self.unfold_qp
|
||||
&& buf.last().or_else(|| self.input.get(offset_end)).copied() == Some(b'=')
|
||||
{
|
||||
offset_end = idx;
|
||||
|
||||
if !buf.is_empty() {
|
||||
buf.push(*ch);
|
||||
}
|
||||
} else if self.try_unfold() {
|
||||
if buf.is_empty() && offset_start != usize::MAX {
|
||||
buf.extend_from_slice(&self.input[offset_start..=offset_end]);
|
||||
}
|
||||
|
||||
if skipped_ws != usize::MAX && !buf.is_empty() {
|
||||
buf.extend(
|
||||
self.input[skipped_ws..idx]
|
||||
.iter()
|
||||
.filter(|ch| matches!(ch, b' ' | b'\t')),
|
||||
);
|
||||
}
|
||||
skipped_ws = usize::MAX;
|
||||
} else if self.unfold_b64 && self.is_base64_continuation(idx) {
|
||||
if buf.is_empty() && offset_start != usize::MAX {
|
||||
buf.extend_from_slice(&self.input[offset_start..=offset_end]);
|
||||
}
|
||||
skipped_ws = usize::MAX;
|
||||
} else {
|
||||
stop_char = StopChar::Lf;
|
||||
break;
|
||||
}
|
||||
}
|
||||
b'\\' => {
|
||||
let mut next_ch = b'\\';
|
||||
let mut next_offset_end = idx;
|
||||
while let Some((idx, ch)) = self.iter.next() {
|
||||
match ch {
|
||||
b'\t' | b'\r' => {}
|
||||
b'\n' => {
|
||||
if self.try_unfold() {
|
||||
if let Some((idx, ch)) = self.iter.next() {
|
||||
next_ch = *ch;
|
||||
next_offset_end = idx;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
stop_char = StopChar::Lf;
|
||||
offset_end = idx - 1;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
next_ch = *ch;
|
||||
next_offset_end = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if offset_start != usize::MAX {
|
||||
if buf.is_empty() {
|
||||
buf.extend_from_slice(&self.input[offset_start..=offset_end]);
|
||||
}
|
||||
|
||||
if skipped_ws != usize::MAX {
|
||||
buf.extend(
|
||||
self.input[skipped_ws..idx]
|
||||
.iter()
|
||||
.filter(|ch| matches!(ch, b' ' | b'\t')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
offset_start = next_offset_end;
|
||||
}
|
||||
skipped_ws = usize::MAX;
|
||||
buf.push(match next_ch {
|
||||
b'n' | b'N' => b'\n',
|
||||
b'r' | b'R' => b'\r',
|
||||
_ => next_ch,
|
||||
});
|
||||
offset_end = next_offset_end;
|
||||
}
|
||||
b'"' if self.unquote => {
|
||||
in_quote = !in_quote;
|
||||
}
|
||||
b':' if !in_quote && self.stop_colon => {
|
||||
stop_char = StopChar::Colon;
|
||||
break;
|
||||
}
|
||||
b';' if !in_quote && self.stop_semicolon => {
|
||||
stop_char = StopChar::Semicolon;
|
||||
break;
|
||||
}
|
||||
b',' if !in_quote && self.stop_comma => {
|
||||
stop_char = StopChar::Comma;
|
||||
break;
|
||||
}
|
||||
b'=' if !in_quote && self.stop_equal => {
|
||||
stop_char = StopChar::Equal;
|
||||
break;
|
||||
}
|
||||
b'.' if !in_quote && self.stop_dot => {
|
||||
stop_char = StopChar::Dot;
|
||||
break;
|
||||
}
|
||||
0x00..=0x08 | 0x0B | 0x0C | 0x0E..=0x1F | 0x7F if self.strip_ctl => {
|
||||
if buf.is_empty() && offset_start != usize::MAX {
|
||||
buf.extend_from_slice(&self.input[offset_start..=offset_end]);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if offset_start == usize::MAX {
|
||||
offset_start = idx;
|
||||
}
|
||||
offset_end = idx;
|
||||
skipped_ws = usize::MAX;
|
||||
|
||||
if !buf.is_empty() {
|
||||
buf.push(*ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if offset_start != usize::MAX {
|
||||
self.last_token_end = offset_end;
|
||||
}
|
||||
|
||||
if buf.is_empty() {
|
||||
if offset_start != usize::MAX {
|
||||
Some(Token {
|
||||
text: Cow::Borrowed(&self.input[offset_start..=offset_end]),
|
||||
start: offset_start,
|
||||
end: offset_end,
|
||||
stop_char,
|
||||
})
|
||||
} else {
|
||||
Some(Token {
|
||||
text: Cow::Borrowed(b"".as_ref()),
|
||||
start: last_idx,
|
||||
end: last_idx,
|
||||
stop_char,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
Some(Token {
|
||||
text: Cow::Owned(buf),
|
||||
start: offset_start,
|
||||
end: offset_end,
|
||||
stop_char,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn token_until_lf(&mut self, last_stop_char: &mut StopChar) -> Option<Token<'x>> {
|
||||
if last_stop_char != &StopChar::Lf {
|
||||
self.token()
|
||||
.inspect(|token| *last_stop_char = token.stop_char)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn parse_value_until_lf<T>(
|
||||
&mut self,
|
||||
separator: StopChar,
|
||||
last_stop_char: &mut StopChar,
|
||||
) -> Option<Option<T>>
|
||||
where
|
||||
T: IanaParse + 'static,
|
||||
{
|
||||
if *last_stop_char != separator {
|
||||
self.token_until_lf(last_stop_char)
|
||||
.map(|token| T::parse(token.text.as_ref()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn seek_lf(&mut self) -> bool {
|
||||
loop {
|
||||
match self.token() {
|
||||
Some(Token {
|
||||
stop_char: StopChar::Lf,
|
||||
..
|
||||
}) => return true,
|
||||
None => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn seek_value_or_eol(&mut self) -> StopChar {
|
||||
loop {
|
||||
match self.token() {
|
||||
Some(Token {
|
||||
stop_char: StopChar::Colon,
|
||||
..
|
||||
}) => return StopChar::Colon,
|
||||
Some(Token {
|
||||
stop_char: StopChar::Lf,
|
||||
..
|
||||
})
|
||||
| None => return StopChar::Lf,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn seek_param_value_or_eol(&mut self) -> StopChar {
|
||||
loop {
|
||||
match self.token() {
|
||||
Some(Token {
|
||||
stop_char: stop_char @ (StopChar::Colon | StopChar::Semicolon | StopChar::Equal),
|
||||
..
|
||||
}) => return stop_char,
|
||||
Some(Token {
|
||||
stop_char: StopChar::Lf,
|
||||
..
|
||||
})
|
||||
| None => return StopChar::Lf,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> Token<'x> {
|
||||
pub fn new(text: Cow<'x, [u8]>) -> Self {
|
||||
Self {
|
||||
text,
|
||||
start: 0,
|
||||
end: 0,
|
||||
stop_char: StopChar::Lf,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
String::from_utf8(self.text.into_owned())
|
||||
.unwrap_or_else(|err| String::from_utf8_lossy(&err.into_bytes()).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum TextOwner {
|
||||
Borrowed(&'static str),
|
||||
Owned(String),
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenizer() {
|
||||
for (input, expected, disable_stop) in [
|
||||
(
|
||||
concat!(
|
||||
"NOTE:This is a long descrip\n",
|
||||
" tion that exists o\n",
|
||||
" n a long line.",
|
||||
),
|
||||
vec![
|
||||
(TextOwner::Borrowed("NOTE"), StopChar::Colon),
|
||||
(
|
||||
TextOwner::Owned(
|
||||
"This is a long description that exists on a long line.".into(),
|
||||
),
|
||||
StopChar::Lf,
|
||||
),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
"this is a text value\n",
|
||||
"this is one value,this is another\n",
|
||||
"this is a single value\\, with a comma encoded\n"
|
||||
),
|
||||
vec![
|
||||
(TextOwner::Borrowed("this is a text value"), StopChar::Lf),
|
||||
(TextOwner::Borrowed("this is one value"), StopChar::Comma),
|
||||
(TextOwner::Borrowed("this is another"), StopChar::Lf),
|
||||
(
|
||||
TextOwner::Owned("this is a single value, with a comma encoded".into()),
|
||||
StopChar::Lf,
|
||||
),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
"N;ALTID=1;LANGUAGE=en:Yamada;Taro;;;",
|
||||
vec![
|
||||
(TextOwner::Borrowed("N"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("ALTID"), StopChar::Equal),
|
||||
(TextOwner::Borrowed("1"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("LANGUAGE"), StopChar::Equal),
|
||||
(TextOwner::Borrowed("en"), StopChar::Colon),
|
||||
(TextOwner::Borrowed("Yamada"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("Taro"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed(""), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed(""), StopChar::Semicolon),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
"N;SORT-AS=\"Mann,James\":de Mann;Henry,James;;",
|
||||
vec![
|
||||
(TextOwner::Borrowed("N"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("SORT-AS"), StopChar::Equal),
|
||||
(TextOwner::Borrowed("Mann,James"), StopChar::Colon),
|
||||
(TextOwner::Borrowed("de Mann"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("Henry"), StopChar::Comma),
|
||||
(TextOwner::Borrowed("James"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed(""), StopChar::Semicolon),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
" hello\\ nworld\\\\",
|
||||
vec![(TextOwner::Owned("hello nworld\\".into()), StopChar::Lf)],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
"X-ABC-MMSUBJ;VALUE=URI;FMTTYPE=audio/basic:http://www.example.\n",
|
||||
" org/mysubj.au"
|
||||
),
|
||||
vec![
|
||||
(TextOwner::Borrowed("X-ABC-MMSUBJ"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("VALUE"), StopChar::Equal),
|
||||
(TextOwner::Borrowed("URI"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("FMTTYPE"), StopChar::Equal),
|
||||
(TextOwner::Borrowed("audio/basic"), StopChar::Colon),
|
||||
(TextOwner::Borrowed("http"), StopChar::Colon),
|
||||
(
|
||||
TextOwner::Owned("//www.example.org/mysubj.au".into()),
|
||||
StopChar::Lf,
|
||||
),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
"RDATE;VALUE=DATE:19970304,19970504,19970704,19970904",
|
||||
vec![
|
||||
(TextOwner::Borrowed("RDATE"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("VALUE"), StopChar::Equal),
|
||||
(TextOwner::Borrowed("DATE"), StopChar::Colon),
|
||||
(TextOwner::Borrowed("19970304"), StopChar::Comma),
|
||||
(TextOwner::Borrowed("19970504"), StopChar::Comma),
|
||||
(TextOwner::Borrowed("19970704"), StopChar::Comma),
|
||||
(TextOwner::Borrowed("19970904"), StopChar::Lf),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
" BEGIN; ::\n \n \n test",
|
||||
vec![
|
||||
(TextOwner::Borrowed("BEGIN"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed(""), StopChar::Colon),
|
||||
(TextOwner::Borrowed(""), StopChar::Colon),
|
||||
(TextOwner::Borrowed("test"), StopChar::Lf),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
"DESCRIPTION;Sunday - Partly cloudy with a 20 percent chance of snow show",
|
||||
"ers. Highs in the lower to mid 40s.\\n<a href=\\\"http://www.wunderground.c",
|
||||
"om/US/WA/Leavenworth.html\\\">More Information</a>"
|
||||
),
|
||||
vec![
|
||||
(TextOwner::Borrowed("DESCRIPTION"), StopChar::Semicolon),
|
||||
(
|
||||
TextOwner::Owned(
|
||||
concat!(
|
||||
"Sunday - Partly cloudy with a 20 percent ",
|
||||
"chance of snow showers. Highs in the lower ",
|
||||
"to mid 40s.\n<a href=\"http://www.wunderground.com",
|
||||
"/US/WA/Leavenworth.html\">More Information</a>"
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
StopChar::Lf,
|
||||
),
|
||||
],
|
||||
b"=:".as_slice(),
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
"ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:VGhlIH\n",
|
||||
" F1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4"
|
||||
),
|
||||
vec![
|
||||
(TextOwner::Borrowed("ATTACH"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("FMTTYPE"), StopChar::Equal),
|
||||
(TextOwner::Borrowed("text/plain"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("ENCODING"), StopChar::Equal),
|
||||
(TextOwner::Borrowed("BASE64"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("VALUE"), StopChar::Equal),
|
||||
(TextOwner::Borrowed("BINARY"), StopChar::Colon),
|
||||
(
|
||||
TextOwner::Owned(
|
||||
"VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4".into(),
|
||||
),
|
||||
StopChar::Lf,
|
||||
),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
"DESCRIPTION;ALTREP=\"cid:[email protected]\":The Fall'98 Wild\n",
|
||||
" Wizards Conference - - Las Vegas\\, NV\\, USA"
|
||||
),
|
||||
vec![
|
||||
(TextOwner::Borrowed("DESCRIPTION"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("ALTREP"), StopChar::Equal),
|
||||
(
|
||||
TextOwner::Borrowed("cid:[email protected]"),
|
||||
StopChar::Colon,
|
||||
),
|
||||
(
|
||||
TextOwner::Owned(
|
||||
"The Fall'98 WildWizards Conference - - Las Vegas, NV, USA".to_string(),
|
||||
),
|
||||
StopChar::Lf,
|
||||
),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
"ATTENDEE;DELEGATED-FROM=\"mailto:[email protected]\":mailto:\n",
|
||||
" [email protected]"
|
||||
),
|
||||
vec![
|
||||
(TextOwner::Borrowed("ATTENDEE"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("DELEGATED-FROM"), StopChar::Equal),
|
||||
(
|
||||
TextOwner::Borrowed("mailto:[email protected]"),
|
||||
StopChar::Colon,
|
||||
),
|
||||
(TextOwner::Borrowed("mailto"), StopChar::Colon),
|
||||
(TextOwner::Borrowed("[email protected]"), StopChar::Lf),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
"ATTENDEE;DELEGATED-TO=\"mailto:[email protected]\",\"mailto:jqpublic\n",
|
||||
" @example.com\":mailto:[email protected]"
|
||||
),
|
||||
vec![
|
||||
(TextOwner::Borrowed("ATTENDEE"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("DELEGATED-TO"), StopChar::Equal),
|
||||
(
|
||||
TextOwner::Borrowed("mailto:[email protected]"),
|
||||
StopChar::Comma,
|
||||
),
|
||||
(
|
||||
TextOwner::Owned("mailto:[email protected]".into()),
|
||||
StopChar::Colon,
|
||||
),
|
||||
(TextOwner::Borrowed("mailto"), StopChar::Colon),
|
||||
(TextOwner::Borrowed("[email protected]"), StopChar::Lf),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
"CATEGORIES:cat1 , cat2, cat3",
|
||||
vec![
|
||||
(TextOwner::Borrowed("CATEGORIES"), StopChar::Colon),
|
||||
(TextOwner::Borrowed("cat1"), StopChar::Comma),
|
||||
(TextOwner::Borrowed("cat2"), StopChar::Comma),
|
||||
(TextOwner::Borrowed("cat3"), StopChar::Lf),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
concat!("SUMMARY:Meeting\n", "\n\n", "BEGIN:VALARM"),
|
||||
vec![
|
||||
(TextOwner::Borrowed("SUMMARY"), StopChar::Colon),
|
||||
(TextOwner::Borrowed("Meeting"), StopChar::Lf),
|
||||
(TextOwner::Borrowed(""), StopChar::Lf),
|
||||
(TextOwner::Borrowed(""), StopChar::Lf),
|
||||
(TextOwner::Borrowed("BEGIN"), StopChar::Colon),
|
||||
(TextOwner::Borrowed("VALARM"), StopChar::Lf),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
"FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=D0=B3=D0=BE=D1=80=20",
|
||||
"=D0=97=D0=B0=D0=BE=D1=80=D1=81=D0=81=D0=\n",
|
||||
"=BA=\n",
|
||||
"=D1=96=\n",
|
||||
" xyz =\n",
|
||||
"=D1=96\n"
|
||||
),
|
||||
vec![
|
||||
(TextOwner::Borrowed("FN"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("CHARSET"), StopChar::Equal),
|
||||
(TextOwner::Borrowed("UTF-8"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("ENCODING"), StopChar::Equal),
|
||||
(TextOwner::Borrowed("QUOTED-PRINTABLE"), StopChar::Colon),
|
||||
(
|
||||
TextOwner::Borrowed(concat!(
|
||||
"=D0=B3=D0=BE=D1=80=20=D0=97=D0=B0=D0=BE=D1=80",
|
||||
"=D1=81=D0=81=D0=\n=BA=\n=D1=96=\n xyz =\n=D1=96"
|
||||
)),
|
||||
StopChar::Lf,
|
||||
),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
"ADR;LABEL=\"Mr. John Q. Public, Esq.\\nMail Drop: TNE QB\\n123\n",
|
||||
" Main Street\\nAny Town, CA 91921-1234\\nU.S.A.\":\n",
|
||||
" ;;123 Main Street;Any Town;CA;91921-1234;U.S.A."
|
||||
),
|
||||
vec![
|
||||
(TextOwner::Borrowed("ADR"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("LABEL"), StopChar::Equal),
|
||||
(
|
||||
TextOwner::Owned(
|
||||
concat!(
|
||||
"Mr. John Q. Public, Esq.\nMail Drop: TNE QB\n",
|
||||
"123Main Street\nAny Town, CA 91921-1234\nU.S.A."
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
StopChar::Colon,
|
||||
),
|
||||
(TextOwner::Borrowed(""), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed(""), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("123 Main Street"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("Any Town"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("CA"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("91921-1234"), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed("U.S.A."), StopChar::Lf),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
"\\",
|
||||
vec![(TextOwner::Owned("\\".into()), StopChar::Lf)],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
"\\n",
|
||||
vec![(TextOwner::Owned("\n".into()), StopChar::Lf)],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
"\\nhello",
|
||||
vec![(TextOwner::Owned("\nhello".into()), StopChar::Lf)],
|
||||
b"".as_slice(),
|
||||
),
|
||||
(
|
||||
";;\nEND:VCARD\n",
|
||||
vec![
|
||||
(TextOwner::Borrowed(""), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed(""), StopChar::Semicolon),
|
||||
(TextOwner::Borrowed(""), StopChar::Lf),
|
||||
(TextOwner::Borrowed("END"), StopChar::Colon),
|
||||
(TextOwner::Borrowed("VCARD"), StopChar::Lf),
|
||||
],
|
||||
b"".as_slice(),
|
||||
),
|
||||
("", vec![], b"".as_slice()),
|
||||
] {
|
||||
let mut parser = Parser::new(input);
|
||||
let mut tokens = vec![];
|
||||
parser.skip_ws = true;
|
||||
for ch in disable_stop {
|
||||
match ch {
|
||||
b'=' => {
|
||||
parser.stop_equal = false;
|
||||
}
|
||||
b':' => {
|
||||
parser.stop_colon = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(token) = parser.token() {
|
||||
if token.text.eq_ignore_ascii_case(b"quoted-printable") {
|
||||
parser.stop_colon = false;
|
||||
parser.stop_comma = false;
|
||||
parser.stop_equal = false;
|
||||
parser.unquote = false;
|
||||
parser.unfold_qp = true;
|
||||
parser.stop_semicolon = true;
|
||||
parser.skip_ws = false;
|
||||
}
|
||||
let text = match token.text {
|
||||
Cow::Borrowed(text) => TextOwner::Borrowed(std::str::from_utf8(text).unwrap()),
|
||||
Cow::Owned(text) => TextOwner::Owned(String::from_utf8(text).unwrap()),
|
||||
};
|
||||
tokens.push((text, token.stop_char));
|
||||
}
|
||||
assert_eq!(tokens, expected, "failed for input: {:?}", input);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+451
@@ -0,0 +1,451 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
impl IanaString for CalendarScale {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
CalendarScale::Gregorian => "GREGORIAN",
|
||||
CalendarScale::Chinese => "CHINESE",
|
||||
CalendarScale::IslamicCivil => "ISLAMIC-CIVIL",
|
||||
CalendarScale::Hebrew => "HEBREW",
|
||||
CalendarScale::Ethiopic => "ETHIOPIC",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "jmap")]
|
||||
impl CalendarScale {
|
||||
pub fn as_js_str(&self) -> &'static str {
|
||||
match self {
|
||||
CalendarScale::Gregorian => "gregorian",
|
||||
CalendarScale::Chinese => "chinese",
|
||||
CalendarScale::IslamicCivil => "islamic-civil",
|
||||
CalendarScale::Hebrew => "hebrew",
|
||||
CalendarScale::Ethiopic => "ethiopic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rkyv")]
|
||||
impl IanaString for ArchivedCalendarScale {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedCalendarScale::Gregorian => "GREGORIAN",
|
||||
ArchivedCalendarScale::Chinese => "CHINESE",
|
||||
ArchivedCalendarScale::IslamicCivil => "ISLAMIC-CIVIL",
|
||||
ArchivedCalendarScale::Hebrew => "HEBREW",
|
||||
ArchivedCalendarScale::Ethiopic => "ETHIOPIC",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IanaParse for CalendarScale {
|
||||
fn parse(value: &[u8]) -> Option<Self> {
|
||||
hashify::tiny_map_ignore_case!(value,
|
||||
"gregorian" => CalendarScale::Gregorian,
|
||||
"chinese" => CalendarScale::Chinese,
|
||||
"islamic-civil" => CalendarScale::IslamicCivil,
|
||||
"hebrew" => CalendarScale::Hebrew,
|
||||
"ethiopic" => CalendarScale::Ethiopic,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Encoding {
|
||||
pub fn parse(value: &[u8]) -> Option<Self> {
|
||||
hashify::tiny_map_ignore_case!(value,
|
||||
b"QUOTED-PRINTABLE" => Encoding::QuotedPrintable,
|
||||
b"BASE64" => Encoding::Base64,
|
||||
b"Q" => Encoding::QuotedPrintable,
|
||||
b"B" => Encoding::Base64,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialDateTime {
|
||||
pub fn now() -> Self {
|
||||
Self::from_utc_timestamp(chrono::Utc::now().timestamp())
|
||||
}
|
||||
|
||||
pub fn from_utc_timestamp(value: i64) -> Self {
|
||||
let dt = DateTime::from_timestamp(value);
|
||||
|
||||
PartialDateTime {
|
||||
year: dt.year.into(),
|
||||
month: dt.month.into(),
|
||||
day: dt.day.into(),
|
||||
hour: dt.hour.into(),
|
||||
minute: dt.minute.into(),
|
||||
second: dt.second.into(),
|
||||
tz_hour: dt.tz_hour.into(),
|
||||
tz_minute: dt.tz_minute.into(),
|
||||
tz_minus: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_date_timestamp(value: i64) -> Self {
|
||||
let dt = DateTime::from_timestamp(value);
|
||||
|
||||
PartialDateTime {
|
||||
year: dt.year.into(),
|
||||
month: dt.month.into(),
|
||||
day: dt.day.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_naive_timestamp(value: i64) -> Self {
|
||||
let dt = DateTime::from_timestamp(value);
|
||||
|
||||
PartialDateTime {
|
||||
year: dt.year.into(),
|
||||
month: dt.month.into(),
|
||||
day: dt.day.into(),
|
||||
hour: dt.hour.into(),
|
||||
minute: dt.minute.into(),
|
||||
second: dt.second.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_date_time(&self) -> Option<DateTimeResult> {
|
||||
let mut dt = DateTimeResult {
|
||||
date_time: NaiveDate::from_ymd_opt(
|
||||
self.year? as i32,
|
||||
self.month? as u32,
|
||||
self.day? as u32,
|
||||
)?
|
||||
.and_hms_opt(
|
||||
self.hour.unwrap_or(0) as u32,
|
||||
self.minute.unwrap_or(0) as u32,
|
||||
self.second.unwrap_or(0) as u32,
|
||||
)?,
|
||||
offset: None,
|
||||
};
|
||||
if let Some(tz_hour) = self.tz_hour {
|
||||
let secs = (tz_hour as i32 * 3600) + (self.tz_minute.unwrap_or(0) as i32 * 60);
|
||||
dt.offset = if self.tz_minus {
|
||||
FixedOffset::west_opt(secs)?
|
||||
} else {
|
||||
FixedOffset::east_opt(secs)?
|
||||
}
|
||||
.into();
|
||||
}
|
||||
Some(dt)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rkyv")]
|
||||
impl ArchivedPartialDateTime {
|
||||
pub fn to_date_time(&self) -> Option<DateTimeResult> {
|
||||
let mut dt = DateTimeResult {
|
||||
date_time: NaiveDate::from_ymd_opt(
|
||||
self.year.as_ref()?.to_native() as i32,
|
||||
*self.month.as_ref()? as u32,
|
||||
*self.day.as_ref()? as u32,
|
||||
)?
|
||||
.and_hms_opt(
|
||||
self.hour.unwrap_or(0) as u32,
|
||||
self.minute.unwrap_or(0) as u32,
|
||||
self.second.unwrap_or(0) as u32,
|
||||
)?,
|
||||
offset: None,
|
||||
};
|
||||
if let Some(tz_hour) = self.tz_hour.as_ref() {
|
||||
let secs = (*tz_hour as i32 * 3600) + (self.tz_minute.unwrap_or(0) as i32 * 60);
|
||||
dt.offset = if self.tz_minus {
|
||||
FixedOffset::west_opt(secs)?
|
||||
} else {
|
||||
FixedOffset::east_opt(secs)?
|
||||
}
|
||||
.into();
|
||||
}
|
||||
Some(dt)
|
||||
}
|
||||
}
|
||||
|
||||
impl IanaParse for LinkRelation {
|
||||
fn parse(s: &[u8]) -> Option<Self> {
|
||||
hashify::map_ignore_case!(s, LinkRelation,
|
||||
"about" => LinkRelation::About,
|
||||
"acl" => LinkRelation::Acl,
|
||||
"alternate" => LinkRelation::Alternate,
|
||||
"amphtml" => LinkRelation::Amphtml,
|
||||
"api-catalog" => LinkRelation::ApiCatalog,
|
||||
"appendix" => LinkRelation::Appendix,
|
||||
"apple-touch-icon" => LinkRelation::AppleTouchIcon,
|
||||
"apple-touch-startup-image" => LinkRelation::AppleTouchStartupImage,
|
||||
"archives" => LinkRelation::Archives,
|
||||
"author" => LinkRelation::Author,
|
||||
"blocked-by" => LinkRelation::BlockedBy,
|
||||
"bookmark" => LinkRelation::Bookmark,
|
||||
"c2pa-manifest" => LinkRelation::C2paManifest,
|
||||
"canonical" => LinkRelation::Canonical,
|
||||
"chapter" => LinkRelation::Chapter,
|
||||
"cite-as" => LinkRelation::CiteAs,
|
||||
"collection" => LinkRelation::Collection,
|
||||
"compression-dictionary" => LinkRelation::CompressionDictionary,
|
||||
"contents" => LinkRelation::Contents,
|
||||
"convertedfrom" => LinkRelation::Convertedfrom,
|
||||
"copyright" => LinkRelation::Copyright,
|
||||
"create-form" => LinkRelation::CreateForm,
|
||||
"current" => LinkRelation::Current,
|
||||
"deprecation" => LinkRelation::Deprecation,
|
||||
"describedby" => LinkRelation::Describedby,
|
||||
"describes" => LinkRelation::Describes,
|
||||
"disclosure" => LinkRelation::Disclosure,
|
||||
"dns-prefetch" => LinkRelation::DnsPrefetch,
|
||||
"duplicate" => LinkRelation::Duplicate,
|
||||
"edit" => LinkRelation::Edit,
|
||||
"edit-form" => LinkRelation::EditForm,
|
||||
"edit-media" => LinkRelation::EditMedia,
|
||||
"enclosure" => LinkRelation::Enclosure,
|
||||
"external" => LinkRelation::External,
|
||||
"first" => LinkRelation::First,
|
||||
"geofeed" => LinkRelation::Geofeed,
|
||||
"glossary" => LinkRelation::Glossary,
|
||||
"help" => LinkRelation::Help,
|
||||
"hosts" => LinkRelation::Hosts,
|
||||
"hub" => LinkRelation::Hub,
|
||||
"ice-server" => LinkRelation::IceServer,
|
||||
"icon" => LinkRelation::Icon,
|
||||
"index" => LinkRelation::Index,
|
||||
"intervalafter" => LinkRelation::Intervalafter,
|
||||
"intervalbefore" => LinkRelation::Intervalbefore,
|
||||
"intervalcontains" => LinkRelation::Intervalcontains,
|
||||
"intervaldisjoint" => LinkRelation::Intervaldisjoint,
|
||||
"intervalduring" => LinkRelation::Intervalduring,
|
||||
"intervalequals" => LinkRelation::Intervalequals,
|
||||
"intervalfinishedby" => LinkRelation::Intervalfinishedby,
|
||||
"intervalfinishes" => LinkRelation::Intervalfinishes,
|
||||
"intervalin" => LinkRelation::Intervalin,
|
||||
"intervalmeets" => LinkRelation::Intervalmeets,
|
||||
"intervalmetby" => LinkRelation::Intervalmetby,
|
||||
"intervaloverlappedby" => LinkRelation::Intervaloverlappedby,
|
||||
"intervaloverlaps" => LinkRelation::Intervaloverlaps,
|
||||
"intervalstartedby" => LinkRelation::Intervalstartedby,
|
||||
"intervalstarts" => LinkRelation::Intervalstarts,
|
||||
"item" => LinkRelation::Item,
|
||||
"last" => LinkRelation::Last,
|
||||
"latest-version" => LinkRelation::LatestVersion,
|
||||
"license" => LinkRelation::License,
|
||||
"linkset" => LinkRelation::Linkset,
|
||||
"lrdd" => LinkRelation::Lrdd,
|
||||
"manifest" => LinkRelation::Manifest,
|
||||
"mask-icon" => LinkRelation::MaskIcon,
|
||||
"me" => LinkRelation::Me,
|
||||
"media-feed" => LinkRelation::MediaFeed,
|
||||
"memento" => LinkRelation::Memento,
|
||||
"micropub" => LinkRelation::Micropub,
|
||||
"modulepreload" => LinkRelation::Modulepreload,
|
||||
"monitor" => LinkRelation::Monitor,
|
||||
"monitor-group" => LinkRelation::MonitorGroup,
|
||||
"next" => LinkRelation::Next,
|
||||
"next-archive" => LinkRelation::NextArchive,
|
||||
"nofollow" => LinkRelation::Nofollow,
|
||||
"noopener" => LinkRelation::Noopener,
|
||||
"noreferrer" => LinkRelation::Noreferrer,
|
||||
"opener" => LinkRelation::Opener,
|
||||
"openid2.local_id" => LinkRelation::Openid2LocalId,
|
||||
"openid2.provider" => LinkRelation::Openid2Provider,
|
||||
"original" => LinkRelation::Original,
|
||||
"p3pv1" => LinkRelation::P3pv1,
|
||||
"payment" => LinkRelation::Payment,
|
||||
"pingback" => LinkRelation::Pingback,
|
||||
"preconnect" => LinkRelation::Preconnect,
|
||||
"predecessor-version" => LinkRelation::PredecessorVersion,
|
||||
"prefetch" => LinkRelation::Prefetch,
|
||||
"preload" => LinkRelation::Preload,
|
||||
"prerender" => LinkRelation::Prerender,
|
||||
"prev" => LinkRelation::Prev,
|
||||
"preview" => LinkRelation::Preview,
|
||||
"previous" => LinkRelation::Previous,
|
||||
"prev-archive" => LinkRelation::PrevArchive,
|
||||
"privacy-policy" => LinkRelation::PrivacyPolicy,
|
||||
"profile" => LinkRelation::Profile,
|
||||
"publication" => LinkRelation::Publication,
|
||||
"rdap-active" => LinkRelation::RdapActive,
|
||||
"rdap-bottom" => LinkRelation::RdapBottom,
|
||||
"rdap-down" => LinkRelation::RdapDown,
|
||||
"rdap-top" => LinkRelation::RdapTop,
|
||||
"rdap-up" => LinkRelation::RdapUp,
|
||||
"related" => LinkRelation::Related,
|
||||
"restconf" => LinkRelation::Restconf,
|
||||
"replies" => LinkRelation::Replies,
|
||||
"ruleinput" => LinkRelation::Ruleinput,
|
||||
"search" => LinkRelation::Search,
|
||||
"section" => LinkRelation::Section,
|
||||
"self" => LinkRelation::Self_,
|
||||
"service" => LinkRelation::Service,
|
||||
"service-desc" => LinkRelation::ServiceDesc,
|
||||
"service-doc" => LinkRelation::ServiceDoc,
|
||||
"service-meta" => LinkRelation::ServiceMeta,
|
||||
"sip-trunking-capability" => LinkRelation::SipTrunkingCapability,
|
||||
"sponsored" => LinkRelation::Sponsored,
|
||||
"start" => LinkRelation::Start,
|
||||
"status" => LinkRelation::Status,
|
||||
"stylesheet" => LinkRelation::Stylesheet,
|
||||
"subsection" => LinkRelation::Subsection,
|
||||
"successor-version" => LinkRelation::SuccessorVersion,
|
||||
"sunset" => LinkRelation::Sunset,
|
||||
"tag" => LinkRelation::Tag,
|
||||
"terms-of-service" => LinkRelation::TermsOfService,
|
||||
"timegate" => LinkRelation::Timegate,
|
||||
"timemap" => LinkRelation::Timemap,
|
||||
"type" => LinkRelation::Type,
|
||||
"ugc" => LinkRelation::Ugc,
|
||||
"up" => LinkRelation::Up,
|
||||
"version-history" => LinkRelation::VersionHistory,
|
||||
"via" => LinkRelation::Via,
|
||||
"webmention" => LinkRelation::Webmention,
|
||||
"working-copy" => LinkRelation::WorkingCopy,
|
||||
"working-copy-of" => LinkRelation::WorkingCopyOf,
|
||||
)
|
||||
.copied()
|
||||
}
|
||||
}
|
||||
|
||||
impl IanaString for LinkRelation {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
LinkRelation::About => "about",
|
||||
LinkRelation::Acl => "acl",
|
||||
LinkRelation::Alternate => "alternate",
|
||||
LinkRelation::Amphtml => "amphtml",
|
||||
LinkRelation::ApiCatalog => "api-catalog",
|
||||
LinkRelation::Appendix => "appendix",
|
||||
LinkRelation::AppleTouchIcon => "apple-touch-icon",
|
||||
LinkRelation::AppleTouchStartupImage => "apple-touch-startup-image",
|
||||
LinkRelation::Archives => "archives",
|
||||
LinkRelation::Author => "author",
|
||||
LinkRelation::BlockedBy => "blocked-by",
|
||||
LinkRelation::Bookmark => "bookmark",
|
||||
LinkRelation::C2paManifest => "c2pa-manifest",
|
||||
LinkRelation::Canonical => "canonical",
|
||||
LinkRelation::Chapter => "chapter",
|
||||
LinkRelation::CiteAs => "cite-as",
|
||||
LinkRelation::Collection => "collection",
|
||||
LinkRelation::CompressionDictionary => "compression-dictionary",
|
||||
LinkRelation::Contents => "contents",
|
||||
LinkRelation::Convertedfrom => "convertedfrom",
|
||||
LinkRelation::Copyright => "copyright",
|
||||
LinkRelation::CreateForm => "create-form",
|
||||
LinkRelation::Current => "current",
|
||||
LinkRelation::Deprecation => "deprecation",
|
||||
LinkRelation::Describedby => "describedby",
|
||||
LinkRelation::Describes => "describes",
|
||||
LinkRelation::Disclosure => "disclosure",
|
||||
LinkRelation::DnsPrefetch => "dns-prefetch",
|
||||
LinkRelation::Duplicate => "duplicate",
|
||||
LinkRelation::Edit => "edit",
|
||||
LinkRelation::EditForm => "edit-form",
|
||||
LinkRelation::EditMedia => "edit-media",
|
||||
LinkRelation::Enclosure => "enclosure",
|
||||
LinkRelation::External => "external",
|
||||
LinkRelation::First => "first",
|
||||
LinkRelation::Geofeed => "geofeed",
|
||||
LinkRelation::Glossary => "glossary",
|
||||
LinkRelation::Help => "help",
|
||||
LinkRelation::Hosts => "hosts",
|
||||
LinkRelation::Hub => "hub",
|
||||
LinkRelation::IceServer => "ice-server",
|
||||
LinkRelation::Icon => "icon",
|
||||
LinkRelation::Index => "index",
|
||||
LinkRelation::Intervalafter => "intervalafter",
|
||||
LinkRelation::Intervalbefore => "intervalbefore",
|
||||
LinkRelation::Intervalcontains => "intervalcontains",
|
||||
LinkRelation::Intervaldisjoint => "intervaldisjoint",
|
||||
LinkRelation::Intervalduring => "intervalduring",
|
||||
LinkRelation::Intervalequals => "intervalequals",
|
||||
LinkRelation::Intervalfinishedby => "intervalfinishedby",
|
||||
LinkRelation::Intervalfinishes => "intervalfinishes",
|
||||
LinkRelation::Intervalin => "intervalin",
|
||||
LinkRelation::Intervalmeets => "intervalmeets",
|
||||
LinkRelation::Intervalmetby => "intervalmetby",
|
||||
LinkRelation::Intervaloverlappedby => "intervaloverlappedby",
|
||||
LinkRelation::Intervaloverlaps => "intervaloverlaps",
|
||||
LinkRelation::Intervalstartedby => "intervalstartedby",
|
||||
LinkRelation::Intervalstarts => "intervalstarts",
|
||||
LinkRelation::Item => "item",
|
||||
LinkRelation::Last => "last",
|
||||
LinkRelation::LatestVersion => "latest-version",
|
||||
LinkRelation::License => "license",
|
||||
LinkRelation::Linkset => "linkset",
|
||||
LinkRelation::Lrdd => "lrdd",
|
||||
LinkRelation::Manifest => "manifest",
|
||||
LinkRelation::MaskIcon => "mask-icon",
|
||||
LinkRelation::Me => "me",
|
||||
LinkRelation::MediaFeed => "media-feed",
|
||||
LinkRelation::Memento => "memento",
|
||||
LinkRelation::Micropub => "micropub",
|
||||
LinkRelation::Modulepreload => "modulepreload",
|
||||
LinkRelation::Monitor => "monitor",
|
||||
LinkRelation::MonitorGroup => "monitor-group",
|
||||
LinkRelation::Next => "next",
|
||||
LinkRelation::NextArchive => "next-archive",
|
||||
LinkRelation::Nofollow => "nofollow",
|
||||
LinkRelation::Noopener => "noopener",
|
||||
LinkRelation::Noreferrer => "noreferrer",
|
||||
LinkRelation::Opener => "opener",
|
||||
LinkRelation::Openid2LocalId => "openid2.local_id",
|
||||
LinkRelation::Openid2Provider => "openid2.provider",
|
||||
LinkRelation::Original => "original",
|
||||
LinkRelation::P3pv1 => "p3pv1",
|
||||
LinkRelation::Payment => "payment",
|
||||
LinkRelation::Pingback => "pingback",
|
||||
LinkRelation::Preconnect => "preconnect",
|
||||
LinkRelation::PredecessorVersion => "predecessor-version",
|
||||
LinkRelation::Prefetch => "prefetch",
|
||||
LinkRelation::Preload => "preload",
|
||||
LinkRelation::Prerender => "prerender",
|
||||
LinkRelation::Prev => "prev",
|
||||
LinkRelation::Preview => "preview",
|
||||
LinkRelation::Previous => "previous",
|
||||
LinkRelation::PrevArchive => "prev-archive",
|
||||
LinkRelation::PrivacyPolicy => "privacy-policy",
|
||||
LinkRelation::Profile => "profile",
|
||||
LinkRelation::Publication => "publication",
|
||||
LinkRelation::RdapActive => "rdap-active",
|
||||
LinkRelation::RdapBottom => "rdap-bottom",
|
||||
LinkRelation::RdapDown => "rdap-down",
|
||||
LinkRelation::RdapTop => "rdap-top",
|
||||
LinkRelation::RdapUp => "rdap-up",
|
||||
LinkRelation::Related => "related",
|
||||
LinkRelation::Restconf => "restconf",
|
||||
LinkRelation::Replies => "replies",
|
||||
LinkRelation::Ruleinput => "ruleinput",
|
||||
LinkRelation::Search => "search",
|
||||
LinkRelation::Section => "section",
|
||||
LinkRelation::Self_ => "self",
|
||||
LinkRelation::Service => "service",
|
||||
LinkRelation::ServiceDesc => "service-desc",
|
||||
LinkRelation::ServiceDoc => "service-doc",
|
||||
LinkRelation::ServiceMeta => "service-meta",
|
||||
LinkRelation::SipTrunkingCapability => "sip-trunking-capability",
|
||||
LinkRelation::Sponsored => "sponsored",
|
||||
LinkRelation::Start => "start",
|
||||
LinkRelation::Status => "status",
|
||||
LinkRelation::Stylesheet => "stylesheet",
|
||||
LinkRelation::Subsection => "subsection",
|
||||
LinkRelation::SuccessorVersion => "successor-version",
|
||||
LinkRelation::Sunset => "sunset",
|
||||
LinkRelation::Tag => "tag",
|
||||
LinkRelation::TermsOfService => "terms-of-service",
|
||||
LinkRelation::Timegate => "timegate",
|
||||
LinkRelation::Timemap => "timemap",
|
||||
LinkRelation::Type => "type",
|
||||
LinkRelation::Ugc => "ugc",
|
||||
LinkRelation::Up => "up",
|
||||
LinkRelation::VersionHistory => "version-history",
|
||||
LinkRelation::Via => "via",
|
||||
LinkRelation::Webmention => "webmention",
|
||||
LinkRelation::WorkingCopy => "working-copy",
|
||||
LinkRelation::WorkingCopyOf => "working-copy-of",
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+276
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::parser::Timestamp;
|
||||
use crate::vcard::Jscomp;
|
||||
use mail_builder::encoders::base64::*;
|
||||
use mail_parser::DateTime;
|
||||
use std::fmt::{Display, Write};
|
||||
|
||||
pub(crate) const MAX_LINE_LEN: usize = 75;
|
||||
|
||||
pub(crate) trait LineWriter: Write {
|
||||
fn write_atomic(&mut self, text: &str) -> std::fmt::Result;
|
||||
}
|
||||
|
||||
pub(crate) struct FoldingWriter<'x, W: Write> {
|
||||
out: &'x mut W,
|
||||
line_len: usize,
|
||||
}
|
||||
|
||||
impl<'x, W: Write> FoldingWriter<'x, W> {
|
||||
pub(crate) fn new(out: &'x mut W) -> Self {
|
||||
Self { out, line_len: 0 }
|
||||
}
|
||||
|
||||
pub(crate) fn end_line(&mut self) -> std::fmt::Result {
|
||||
self.line_len = 0;
|
||||
self.out.write_str("\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Write> LineWriter for FoldingWriter<'_, W> {
|
||||
fn write_atomic(&mut self, text: &str) -> std::fmt::Result {
|
||||
let len = text.len();
|
||||
if len > MAX_LINE_LEN - 1 {
|
||||
return self.write_str(text);
|
||||
}
|
||||
if self.line_len + len > MAX_LINE_LEN {
|
||||
self.out.write_str("\r\n ")?;
|
||||
self.line_len = 1;
|
||||
}
|
||||
self.out.write_str(text)?;
|
||||
self.line_len += len;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl LineWriter for String {
|
||||
fn write_atomic(&mut self, text: &str) -> std::fmt::Result {
|
||||
self.push_str(text);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Write> Write for FoldingWriter<'_, W> {
|
||||
fn write_str(&mut self, text: &str) -> std::fmt::Result {
|
||||
let mut rest = text;
|
||||
|
||||
loop {
|
||||
let available = MAX_LINE_LEN.saturating_sub(self.line_len);
|
||||
|
||||
if rest.len() <= available {
|
||||
self.line_len += rest.len();
|
||||
return self.out.write_str(rest);
|
||||
}
|
||||
|
||||
let mut split = available;
|
||||
while split > 0 && !rest.is_char_boundary(split) {
|
||||
split -= 1;
|
||||
}
|
||||
|
||||
if split == 0 {
|
||||
self.out.write_str("\r\n ")?;
|
||||
self.line_len = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
self.out.write_str(&rest[..split])?;
|
||||
self.line_len += split;
|
||||
rest = &rest[split..];
|
||||
}
|
||||
}
|
||||
|
||||
fn write_char(&mut self, ch: char) -> std::fmt::Result {
|
||||
let ch_len = ch.len_utf8();
|
||||
if self.line_len + ch_len > MAX_LINE_LEN {
|
||||
self.out.write_str("\r\n ")?;
|
||||
self.line_len = 1;
|
||||
}
|
||||
self.line_len += ch_len;
|
||||
self.out.write_char(ch)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_text(
|
||||
out: &mut impl LineWriter,
|
||||
value: &str,
|
||||
escape_semicolon: bool,
|
||||
escape_comma: bool,
|
||||
) -> std::fmt::Result {
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'\r' => out.write_atomic("\\r")?,
|
||||
'\n' => out.write_atomic("\\n")?,
|
||||
'\\' => out.write_atomic("\\\\")?,
|
||||
';' if escape_semicolon => out.write_atomic("\\;")?,
|
||||
',' if escape_comma => out.write_atomic("\\,")?,
|
||||
_ => out.write_char(ch)?,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_bytes(out: &mut impl Write, value: &[u8]) -> std::fmt::Result {
|
||||
const CHARPAD: u8 = b'=';
|
||||
|
||||
let mut i = 0;
|
||||
let mut t1;
|
||||
let mut t2;
|
||||
let mut t3;
|
||||
|
||||
if value.len() > 2 {
|
||||
while i < value.len() - 2 {
|
||||
t1 = value[i];
|
||||
t2 = value[i + 1];
|
||||
t3 = value[i + 2];
|
||||
|
||||
for ch in [
|
||||
E0[t1 as usize],
|
||||
E1[(((t1 & 0x03) << 4) | ((t2 >> 4) & 0x0F)) as usize],
|
||||
E1[(((t2 & 0x0F) << 2) | ((t3 >> 6) & 0x03)) as usize],
|
||||
E2[t3 as usize],
|
||||
] {
|
||||
out.write_char(char::from(ch))?;
|
||||
}
|
||||
|
||||
i += 3;
|
||||
}
|
||||
}
|
||||
|
||||
let remaining = value.len() - i;
|
||||
if remaining > 0 {
|
||||
t1 = value[i];
|
||||
let chs = if remaining == 1 {
|
||||
[
|
||||
E0[t1 as usize],
|
||||
E1[((t1 & 0x03) << 4) as usize],
|
||||
CHARPAD,
|
||||
CHARPAD,
|
||||
]
|
||||
} else {
|
||||
t2 = value[i + 1];
|
||||
[
|
||||
E0[t1 as usize],
|
||||
E1[(((t1 & 0x03) << 4) | ((t2 >> 4) & 0x0F)) as usize],
|
||||
E2[((t2 & 0x0F) << 2) as usize],
|
||||
CHARPAD,
|
||||
]
|
||||
};
|
||||
|
||||
for ch in chs.iter() {
|
||||
out.write_char(char::from(*ch))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) trait NeedsQuotes {
|
||||
fn needs_quotes(&self) -> bool;
|
||||
}
|
||||
|
||||
impl<T: AsRef<[u8]>> NeedsQuotes for T {
|
||||
fn needs_quotes(&self) -> bool {
|
||||
self.as_ref()
|
||||
.iter()
|
||||
.any(|&ch| matches!(ch, b',' | b':' | b'=' | b' ' | b';' | b'"'))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_param_value(out: &mut impl LineWriter, value: &str) -> std::fmt::Result {
|
||||
let needs_quotes = value.needs_quotes();
|
||||
|
||||
if needs_quotes {
|
||||
out.write_atomic("\"")?;
|
||||
}
|
||||
|
||||
for ch in value.chars() {
|
||||
match ch as u32 {
|
||||
0x0A => out.write_atomic("\\n")?,
|
||||
0x0D => out.write_atomic("\\r")?,
|
||||
0x5C => out.write_atomic("\\\\")?,
|
||||
0x22 => out.write_atomic("\\\"")?,
|
||||
0x20 | 0x09 | 0x21 | 0x23..=0x7E | 0x80.. => out.write_char(ch)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if needs_quotes {
|
||||
out.write_atomic("\"")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_jscomps(out: &mut impl LineWriter, values: &[Jscomp]) -> std::fmt::Result {
|
||||
for (pos, item) in values.iter().enumerate() {
|
||||
if pos > 0 {
|
||||
out.write_atomic(";")?;
|
||||
}
|
||||
match item {
|
||||
Jscomp::Entry { position, value } => {
|
||||
write!(out, "{position}")?;
|
||||
if *value > 0 {
|
||||
write!(out, ",{value}")?;
|
||||
}
|
||||
}
|
||||
Jscomp::Separator(s) => {
|
||||
if !s.is_empty() {
|
||||
out.write_atomic("s,")?;
|
||||
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'\\' => out.write_atomic("\\\\")?,
|
||||
',' => out.write_atomic("\\,")?,
|
||||
':' => out.write_atomic("\\:")?,
|
||||
'=' => out.write_atomic("\\=")?,
|
||||
';' => out.write_atomic("\\;")?,
|
||||
'"' => out.write_atomic("\\\"")?,
|
||||
'\r' | '\n' => {}
|
||||
_ => out.write_char(ch)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn assert_fold_width(text: &str, context: &str) {
|
||||
let mut lines = text.split("\r\n").peekable();
|
||||
|
||||
while let Some(line) = lines.next() {
|
||||
assert!(
|
||||
line.len() <= MAX_LINE_LEN,
|
||||
"physical line of {} octets exceeds the {MAX_LINE_LEN} octet fold width in {context}: {line:?}",
|
||||
line.len()
|
||||
);
|
||||
assert!(
|
||||
!line.is_empty() || lines.peek().is_none(),
|
||||
"empty physical line in {context}: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
line != " ",
|
||||
"continuation line holding nothing but the fold character in {context}: {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Timestamp {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let dt = DateTime::from_timestamp(self.0);
|
||||
write!(
|
||||
f,
|
||||
"{:04}{:02}{:02}T{:02}{:02}{:02}Z",
|
||||
dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second
|
||||
)
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::{error::ValidationError, validate::YEAR_RANGE};
|
||||
|
||||
pub(crate) fn check_year_range(year: i32) -> Result<(), ValidationError> {
|
||||
if YEAR_RANGE.contains(&year) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ValidationError::InvalidFieldValueRange {
|
||||
field: "YEAR".into(),
|
||||
value: year.to_string(),
|
||||
start_idx: YEAR_RANGE.start().to_string(),
|
||||
end_idx: YEAR_RANGE.end().to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
+783
@@ -0,0 +1,783 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::{
|
||||
checks,
|
||||
error::{RRuleError, checked_add_u32, checked_mul_u32},
|
||||
masks::MASKS,
|
||||
rrule::RRule,
|
||||
utils::is_leap_year,
|
||||
};
|
||||
use crate::{common::timezone::Tz, icalendar::ICalendarFrequency};
|
||||
use chrono::{Datelike, TimeZone, Timelike, Utc, Weekday};
|
||||
use std::collections::HashSet;
|
||||
|
||||
const MINUTES_IN_A_DAY: u32 = 60 * 24;
|
||||
const SECONDS_IN_A_DAY: u32 = 60 * 60 * 24;
|
||||
|
||||
/// A simple date time type used during iteration.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct DateTimeIter {
|
||||
pub year: i32,
|
||||
pub month: u32,
|
||||
pub day: u32,
|
||||
pub hour: u32,
|
||||
pub minute: u32,
|
||||
pub second: u32,
|
||||
}
|
||||
|
||||
impl DateTimeIter {
|
||||
/// Increments the datetime based on the [`RRule`] values.
|
||||
///
|
||||
/// If `increment_day` is set to `true`, then the method will also make sure that
|
||||
/// the day is incremented. This is useful is the case where `rrule.freq`
|
||||
/// is higher than daily (e.g. hourly) where this function might return a date with the
|
||||
/// same day, but the iterator already knows that the current day can't
|
||||
/// be part of the result.
|
||||
#[cfg(test)]
|
||||
pub fn increment(&mut self, rrule: &RRule, increment_day: bool) -> Result<(), RRuleError> {
|
||||
self.increment_counted(rrule, increment_day, &mut 0)
|
||||
}
|
||||
|
||||
/// inbuxa: `increment`, adding to `steps` each pass of the search loops
|
||||
/// that skip the times `BYHOUR`, `BYMINUTE` and `BYSECOND` exclude, so the
|
||||
/// caller can charge them against its expansion budget.
|
||||
pub fn increment_counted(
|
||||
&mut self,
|
||||
rrule: &RRule,
|
||||
increment_day: bool,
|
||||
steps: &mut u32,
|
||||
) -> Result<(), RRuleError> {
|
||||
let RRule {
|
||||
interval,
|
||||
week_start,
|
||||
by_hour,
|
||||
by_minute,
|
||||
by_second,
|
||||
..
|
||||
} = rrule;
|
||||
match rrule.freq {
|
||||
ICalendarFrequency::Yearly => self.increment_yearly(*interval),
|
||||
ICalendarFrequency::Monthly => self.increment_monthly(*interval),
|
||||
ICalendarFrequency::Weekly => self.increment_weekly(*interval, *week_start),
|
||||
ICalendarFrequency::Daily => self.increment_daily(*interval),
|
||||
ICalendarFrequency::Hourly => {
|
||||
self.increment_hourly(*interval, by_hour, increment_day, steps)
|
||||
}
|
||||
ICalendarFrequency::Minutely => {
|
||||
self.increment_minutely(*interval, by_hour, by_minute, increment_day, steps)
|
||||
}
|
||||
ICalendarFrequency::Secondly => self.increment_secondly(
|
||||
*interval,
|
||||
by_hour,
|
||||
by_minute,
|
||||
by_second,
|
||||
increment_day,
|
||||
steps,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_yearly(&mut self, interval: u16) -> Result<(), RRuleError> {
|
||||
self.year += i32::from(interval);
|
||||
checks::check_year_range(self.year)?;
|
||||
self.fix_day()
|
||||
}
|
||||
|
||||
fn increment_monthly(&mut self, interval: u16) -> Result<(), RRuleError> {
|
||||
self.month += u32::from(interval);
|
||||
if self.month > 12 {
|
||||
let mut year_div = u16::try_from(self.month).map_err(|_| {
|
||||
RRuleError::new_iter_err(
|
||||
"Encountered a too high new month. Please decrease the rrule interval.",
|
||||
)
|
||||
})? / 12;
|
||||
self.month %= 12;
|
||||
if self.month == 0 {
|
||||
self.month = 12;
|
||||
year_div -= 1;
|
||||
}
|
||||
self.year += i32::from(year_div);
|
||||
checks::check_year_range(self.year)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_weekday(&self) -> u32 {
|
||||
let month_range_mask = if is_leap_year(self.year) {
|
||||
&MASKS.month_366_range
|
||||
} else {
|
||||
&MASKS.month_365_range
|
||||
};
|
||||
let year_day = u32::from(month_range_mask[self.month as usize - 1]) + self.day - 1;
|
||||
let year_day_mod = year_day % 7;
|
||||
let year_start_weekday = Utc
|
||||
.with_ymd_and_hms(self.year, 1, 1, 0, 0, 0)
|
||||
// It should never fail, since there is always a 1st of January, is there?
|
||||
.unwrap()
|
||||
.weekday()
|
||||
.num_days_from_monday();
|
||||
|
||||
(year_day_mod + year_start_weekday) % 7
|
||||
}
|
||||
|
||||
fn increment_weekly(&mut self, interval: u16, week_start: Weekday) -> Result<(), RRuleError> {
|
||||
let weekday = self.get_weekday();
|
||||
let option_week_start = week_start.num_days_from_monday();
|
||||
let interval = u32::from(interval);
|
||||
// Calculate the amount of day to move forward.
|
||||
let day_delta = if option_week_start > weekday {
|
||||
// `weekday` and `option_week_start` can only be in range `0..=6`
|
||||
// `option_week_start` > `weekday` so:
|
||||
// `(weekday + 1 + 6 - option_week_start)` > 0 so can never be negative.
|
||||
interval * 7 - (weekday + 7 - option_week_start)
|
||||
} else {
|
||||
// This can also never be negative
|
||||
interval * 7 - (weekday - option_week_start)
|
||||
};
|
||||
self.day += day_delta;
|
||||
self.fix_day()
|
||||
}
|
||||
|
||||
fn days_in_month(&self) -> u32 {
|
||||
if self.month == 0 {
|
||||
31
|
||||
} else {
|
||||
let month_range_mask = if is_leap_year(self.year) {
|
||||
&MASKS.month_366_range
|
||||
} else {
|
||||
&MASKS.month_365_range
|
||||
};
|
||||
let days_in_month =
|
||||
month_range_mask[self.month as usize] - month_range_mask[self.month as usize - 1];
|
||||
u32::from(days_in_month)
|
||||
}
|
||||
}
|
||||
|
||||
fn fix_day(&mut self) -> Result<(), RRuleError> {
|
||||
if self.day <= 28 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut days_in_month = self.days_in_month();
|
||||
if self.day <= days_in_month {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
while self.day > days_in_month {
|
||||
self.day -= days_in_month;
|
||||
self.increment_monthly(1)?;
|
||||
|
||||
days_in_month = self.days_in_month();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn increment_daily(&mut self, interval: u16) -> Result<(), RRuleError> {
|
||||
self.day += u32::from(interval);
|
||||
self.fix_day()
|
||||
}
|
||||
|
||||
fn increment_hourly(
|
||||
&mut self,
|
||||
interval: u16,
|
||||
by_hour: &[u8],
|
||||
increment_day: bool,
|
||||
steps: &mut u32,
|
||||
) -> Result<(), RRuleError> {
|
||||
if increment_day {
|
||||
// Jump to one iteration before the next day
|
||||
let temp_value = (23 - self.hour) / u32::from(interval);
|
||||
self.hour += checked_mul_u32(
|
||||
temp_value,
|
||||
u32::from(interval),
|
||||
Some("please decrease `INTERVAL`"),
|
||||
)?;
|
||||
}
|
||||
|
||||
let mut prev_hours = HashSet::new();
|
||||
loop {
|
||||
*steps = steps.saturating_add(1);
|
||||
self.hour = checked_add_u32(
|
||||
self.hour,
|
||||
u32::from(interval),
|
||||
Some("please decrease `INTERVAL`"),
|
||||
)?;
|
||||
let new_hours = (self.hour % 24) as u8;
|
||||
if by_hour.is_empty() || by_hour.contains(&new_hours) {
|
||||
break;
|
||||
}
|
||||
if prev_hours.contains(&new_hours) {
|
||||
return Err(RRuleError::new_iter_err(
|
||||
"Infinite loop detected. It can be resolved by changing `BYHOUR` or `INTERVAL`",
|
||||
));
|
||||
}
|
||||
prev_hours.insert(new_hours);
|
||||
}
|
||||
|
||||
let new_days = u16::try_from(self.hour / 24).map_err(|_| {
|
||||
RRuleError::new_iter_err("unexpected high hour, please decrease the interval")
|
||||
})?;
|
||||
self.hour %= 24;
|
||||
if new_days > 0 {
|
||||
self.increment_daily(new_days)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_minutely(
|
||||
&mut self,
|
||||
interval: u16,
|
||||
by_hour: &[u8],
|
||||
by_minute: &[u8],
|
||||
increment_day: bool,
|
||||
steps: &mut u32,
|
||||
) -> Result<(), RRuleError> {
|
||||
if increment_day {
|
||||
// Jump to one iteration before the next day
|
||||
let minutes_total = self.hour * 60 + self.minute;
|
||||
let temp_value = (MINUTES_IN_A_DAY - 1 - minutes_total) / u32::from(interval);
|
||||
self.minute += checked_mul_u32(
|
||||
temp_value,
|
||||
u32::from(interval),
|
||||
Some("please decrease `INTERVAL`"),
|
||||
)?;
|
||||
}
|
||||
|
||||
let mut prev_values = HashSet::new();
|
||||
loop {
|
||||
*steps = steps.saturating_add(1);
|
||||
self.minute += u32::from(interval);
|
||||
let hours_div = u16::try_from(self.minute / 60).map_err(|_| {
|
||||
RRuleError::new_iter_err("unexpected high minute, please decrease interval")
|
||||
})?;
|
||||
if hours_div > 0 {
|
||||
self.minute %= 60;
|
||||
self.increment_hourly(hours_div, by_hour, increment_day, steps)?;
|
||||
}
|
||||
|
||||
let hours = (self.hour % 24) as u8;
|
||||
let minutes = (self.minute % 60) as u8;
|
||||
|
||||
if (by_hour.is_empty() || by_hour.contains(&hours))
|
||||
&& (by_minute.is_empty() || by_minute.contains(&minutes))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if prev_values.contains(&(hours, minutes)) {
|
||||
return Err(RRuleError::new_iter_err(
|
||||
"Infinite loop detected. It can be resolved by changing `BYMINUTE`, `BYHOUR` or `INTERVAL`",
|
||||
));
|
||||
}
|
||||
prev_values.insert((hours, minutes));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn increment_secondly(
|
||||
&mut self,
|
||||
interval: u16,
|
||||
by_hour: &[u8],
|
||||
by_minute: &[u8],
|
||||
by_second: &[u8],
|
||||
increment_day: bool,
|
||||
steps: &mut u32,
|
||||
) -> Result<(), RRuleError> {
|
||||
if increment_day {
|
||||
// Jump to one iteration before the next day
|
||||
let total_seconds = self.hour * 3600 + self.minute * 60 + self.second;
|
||||
let temp_value = (SECONDS_IN_A_DAY - 1 - total_seconds) / u32::from(interval);
|
||||
self.second += checked_mul_u32(
|
||||
temp_value,
|
||||
u32::from(interval),
|
||||
Some("please decrease `INTERVAL`"),
|
||||
)?;
|
||||
}
|
||||
|
||||
let mut prev_values = HashSet::new();
|
||||
loop {
|
||||
*steps = steps.saturating_add(1);
|
||||
self.second += u32::from(interval);
|
||||
let minutes_div = u16::try_from(self.second / 60).map_err(|_| {
|
||||
RRuleError::new_iter_err("unexpected high second, please decrease interval")
|
||||
})?;
|
||||
if minutes_div > 0 {
|
||||
self.second %= 60;
|
||||
self.increment_minutely(minutes_div, by_hour, by_minute, increment_day, steps)?;
|
||||
}
|
||||
|
||||
let hours = (self.hour % 24) as u8;
|
||||
let minutes = (self.minute % 60) as u8;
|
||||
let seconds = (self.second % 60) as u8;
|
||||
|
||||
if (by_hour.is_empty() || by_hour.contains(&hours))
|
||||
&& (by_minute.is_empty() || by_minute.contains(&minutes))
|
||||
&& (by_second.is_empty() || by_second.contains(&seconds))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if prev_values.contains(&(hours, minutes, seconds)) {
|
||||
return Err(RRuleError::new_iter_err(
|
||||
"Infinite loop detected. It can be resolved by changing `BYSECOND`, `BYMINUTE`, `BYHOUR` or `INTERVAL`",
|
||||
));
|
||||
}
|
||||
prev_values.insert((hours, minutes, seconds));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&chrono::DateTime<Tz>> for DateTimeIter {
|
||||
fn from(dt: &chrono::DateTime<Tz>) -> Self {
|
||||
Self {
|
||||
year: dt.year(),
|
||||
month: dt.month(),
|
||||
day: dt.day(),
|
||||
hour: dt.hour(),
|
||||
minute: dt.minute(),
|
||||
second: dt.second(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
const UTC: Tz = Tz::Tz(chrono_tz::Tz::UTC);
|
||||
|
||||
fn ymd_hms(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> DateTimeIter {
|
||||
let dt = UTC
|
||||
.with_ymd_and_hms(year, month, day, hour, min, sec)
|
||||
.unwrap();
|
||||
DateTimeIter::from(&dt)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increments_counter_date_with_yearly_freq() {
|
||||
let tests = [
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
1,
|
||||
ymd_hms(1998, 1, 1, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(2020, 2, 29, 0, 0, 0),
|
||||
1,
|
||||
ymd_hms(2021, 3, 1, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
3,
|
||||
ymd_hms(2000, 1, 1, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
20,
|
||||
ymd_hms(2017, 1, 1, 0, 0, 0),
|
||||
),
|
||||
];
|
||||
for (mut counter_date, interval, expected_output) in tests {
|
||||
let rrule = RRule {
|
||||
interval,
|
||||
freq: ICalendarFrequency::Yearly,
|
||||
..Default::default()
|
||||
}
|
||||
.validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let res = counter_date.increment(&rrule, false);
|
||||
assert!(res.is_ok());
|
||||
assert_eq!(counter_date, expected_output);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increments_counter_date_with_monthly_freq() {
|
||||
let tests = [
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
1,
|
||||
ymd_hms(1997, 2, 1, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
11,
|
||||
ymd_hms(1997, 12, 1, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
12,
|
||||
ymd_hms(1998, 1, 1, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 12, 1, 0, 0, 0),
|
||||
1,
|
||||
ymd_hms(1998, 1, 1, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 11, 1, 0, 0, 0),
|
||||
12 * 10,
|
||||
ymd_hms(2007, 11, 1, 0, 0, 0),
|
||||
),
|
||||
];
|
||||
for (mut counter_date, interval, expected_output) in tests {
|
||||
let rrule = RRule {
|
||||
interval,
|
||||
freq: ICalendarFrequency::Monthly,
|
||||
..Default::default()
|
||||
}
|
||||
.validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let res = counter_date.increment(&rrule, false);
|
||||
assert!(res.is_ok());
|
||||
assert_eq!(counter_date, expected_output);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increments_counter_date_with_weekly_freq() {
|
||||
let tests = [
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
1,
|
||||
ymd_hms(1997, 1, 6, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
2,
|
||||
ymd_hms(1997, 1, 13, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
5,
|
||||
ymd_hms(1997, 2, 3, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
52,
|
||||
ymd_hms(1997, 12, 29, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
53,
|
||||
ymd_hms(1998, 1, 5, 0, 0, 0),
|
||||
),
|
||||
];
|
||||
for (mut counter_date, interval, expected_output) in tests {
|
||||
let rrule = RRule {
|
||||
interval,
|
||||
freq: ICalendarFrequency::Weekly,
|
||||
..Default::default()
|
||||
}
|
||||
.validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let res = counter_date.increment(&rrule, false);
|
||||
assert!(res.is_ok());
|
||||
assert_eq!(counter_date, expected_output);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increments_counter_date_with_daily_freq() {
|
||||
let tests = [
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
1,
|
||||
ymd_hms(1997, 1, 2, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
2,
|
||||
ymd_hms(1997, 1, 3, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
31,
|
||||
ymd_hms(1997, 2, 1, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
365,
|
||||
ymd_hms(1998, 1, 1, 0, 0, 0),
|
||||
),
|
||||
];
|
||||
for (mut counter_date, interval, expected_output) in tests {
|
||||
let rrule = RRule {
|
||||
interval,
|
||||
freq: ICalendarFrequency::Daily,
|
||||
..Default::default()
|
||||
}
|
||||
.validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let res = counter_date.increment(&rrule, false);
|
||||
assert!(res.is_ok());
|
||||
assert_eq!(counter_date, expected_output);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increments_counter_date_with_hourly_freq() {
|
||||
let tests = [
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
1,
|
||||
vec![],
|
||||
ymd_hms(1997, 1, 1, 1, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
2,
|
||||
vec![],
|
||||
ymd_hms(1997, 1, 1, 2, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
1,
|
||||
vec![4],
|
||||
ymd_hms(1997, 1, 1, 4, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 10, 0, 0),
|
||||
1,
|
||||
vec![4, 14],
|
||||
ymd_hms(1997, 1, 1, 14, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
24,
|
||||
vec![],
|
||||
ymd_hms(1997, 1, 2, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
24 * 31,
|
||||
vec![],
|
||||
ymd_hms(1997, 2, 1, 0, 0, 0),
|
||||
),
|
||||
];
|
||||
for (mut counter_date, interval, by_hour, expected_output) in tests {
|
||||
let rrule = RRule {
|
||||
interval,
|
||||
freq: ICalendarFrequency::Hourly,
|
||||
by_hour,
|
||||
..Default::default()
|
||||
}
|
||||
.validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let res = counter_date.increment(&rrule, false);
|
||||
assert!(res.is_ok());
|
||||
assert_eq!(counter_date, expected_output);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increments_counter_date_with_hourly_freq_and_day_increment() {
|
||||
let tests = [
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
1,
|
||||
ymd_hms(1997, 1, 2, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
2,
|
||||
ymd_hms(1997, 1, 2, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
5,
|
||||
ymd_hms(1997, 1, 2, 1, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
24,
|
||||
ymd_hms(1997, 1, 2, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
24 * 31,
|
||||
ymd_hms(1997, 2, 1, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
24 * 31 + 1,
|
||||
ymd_hms(1997, 2, 1, 1, 0, 0),
|
||||
),
|
||||
];
|
||||
for (mut counter_date, interval, expected_output) in tests {
|
||||
let rrule = RRule {
|
||||
interval,
|
||||
freq: ICalendarFrequency::Hourly,
|
||||
..Default::default()
|
||||
}
|
||||
.validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let res = counter_date.increment(&rrule, true);
|
||||
assert!(res.is_ok());
|
||||
assert_eq!(counter_date, expected_output);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increments_counter_date_with_minutely_freq() {
|
||||
let tests = [
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
1,
|
||||
ymd_hms(1997, 1, 1, 0, 1, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
2,
|
||||
ymd_hms(1997, 1, 1, 0, 2, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
60,
|
||||
ymd_hms(1997, 1, 1, 1, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
60 * 24,
|
||||
ymd_hms(1997, 1, 2, 0, 0, 0),
|
||||
),
|
||||
];
|
||||
for (mut counter_date, interval, expected_output) in tests {
|
||||
let rrule = RRule {
|
||||
interval,
|
||||
freq: ICalendarFrequency::Minutely,
|
||||
..Default::default()
|
||||
}
|
||||
.validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let res = counter_date.increment(&rrule, false);
|
||||
assert!(res.is_ok());
|
||||
assert_eq!(counter_date, expected_output);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increments_counter_date_with_minutely_freq_and_day_increment() {
|
||||
let tests = [
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
1,
|
||||
ymd_hms(1997, 1, 2, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
2,
|
||||
ymd_hms(1997, 1, 2, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
60 * 24 + 1,
|
||||
ymd_hms(1997, 1, 2, 0, 1, 0),
|
||||
),
|
||||
];
|
||||
for (mut counter_date, interval, expected_output) in tests {
|
||||
let rrule = RRule {
|
||||
interval,
|
||||
freq: ICalendarFrequency::Minutely,
|
||||
..Default::default()
|
||||
}
|
||||
.validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let res = counter_date.increment(&rrule, true);
|
||||
assert!(res.is_ok());
|
||||
assert_eq!(counter_date, expected_output);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increments_counter_date_with_secondly_freq() {
|
||||
let tests = [
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
1,
|
||||
ymd_hms(1997, 1, 1, 0, 0, 1),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
2,
|
||||
ymd_hms(1997, 1, 1, 0, 0, 2),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
60,
|
||||
ymd_hms(1997, 1, 1, 0, 1, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
60 * 60,
|
||||
ymd_hms(1997, 1, 1, 1, 0, 0),
|
||||
),
|
||||
];
|
||||
for (mut counter_date, interval, expected_output) in tests {
|
||||
let rrule = RRule {
|
||||
interval,
|
||||
freq: ICalendarFrequency::Secondly,
|
||||
..Default::default()
|
||||
}
|
||||
.validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let res = counter_date.increment(&rrule, false);
|
||||
assert!(res.is_ok());
|
||||
assert_eq!(counter_date, expected_output);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increments_counter_date_with_secondly_freq_and_day_increment() {
|
||||
let tests = [
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
1,
|
||||
ymd_hms(1997, 1, 2, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
2,
|
||||
ymd_hms(1997, 1, 2, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
ymd_hms(1997, 1, 1, 0, 0, 0),
|
||||
60 * 24 + 1,
|
||||
ymd_hms(1997, 1, 2, 0, 1, 0),
|
||||
),
|
||||
];
|
||||
for (mut counter_date, interval, expected_output) in tests {
|
||||
let rrule = RRule {
|
||||
interval,
|
||||
freq: ICalendarFrequency::Secondly,
|
||||
..Default::default()
|
||||
}
|
||||
.validate(UTC.with_ymd_and_hms(1997, 1, 1, 1, 1, 1).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let res = counter_date.increment(&rrule, true);
|
||||
assert!(res.is_ok());
|
||||
assert_eq!(counter_date, expected_output);
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::masks::MASKS;
|
||||
use super::utils::is_leap_year;
|
||||
|
||||
/// Implementation of the Gauss Easter Algorithm.
|
||||
#[allow(clippy::many_single_char_names)]
|
||||
pub(crate) fn easter(year: i32, offset: i16) -> i32 {
|
||||
let a = year % 19;
|
||||
let b = year / 100;
|
||||
let c = year % 100;
|
||||
let d = b / 4;
|
||||
let e = b % 4;
|
||||
let f = (b + 8) / 25;
|
||||
let g = (b - f + 1) / 3;
|
||||
let h = (19 * a + b - d - g + 15) % 30;
|
||||
let i = c / 4;
|
||||
let k = c % 4;
|
||||
let l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
let m = (a + 11 * h + 22 * l) / 451;
|
||||
let month = ((h + l - 7 * m + 114) / 31) as usize;
|
||||
let day = ((h + l - 7 * m + 114) % 31) + 1 + i32::from(offset);
|
||||
|
||||
let month_range_mask = if is_leap_year(year) {
|
||||
&MASKS.month_366_range
|
||||
} else {
|
||||
&MASKS.month_365_range
|
||||
};
|
||||
|
||||
i32::from(month_range_mask[month - 1]) + day - 1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_easter_masks {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn easter_mask() {
|
||||
let easter_day = easter(1997, 0);
|
||||
assert_eq!(easter_day, 88);
|
||||
let easter_day = easter(1998, 0);
|
||||
assert_eq!(easter_day, 101);
|
||||
let easter_day = easter(1999, 0);
|
||||
assert_eq!(easter_day, 93);
|
||||
let easter_day = easter(2000, 0);
|
||||
assert_eq!(easter_day, 113);
|
||||
}
|
||||
}
|
||||
Vendored
+176
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use crate::{common::IanaString, icalendar::ICalendarFrequency};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
fmt::{Display, Formatter},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(any(test, feature = "serde"), derive(serde::Serialize))]
|
||||
pub enum RRuleError {
|
||||
ValidationError(ValidationError),
|
||||
IterError(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(any(test, feature = "serde"), derive(serde::Serialize))]
|
||||
pub enum ValidationError {
|
||||
BySetPosWithoutByRule,
|
||||
InvalidFieldValue {
|
||||
field: String,
|
||||
value: String,
|
||||
},
|
||||
InvalidFieldValueRange {
|
||||
field: String,
|
||||
value: String,
|
||||
start_idx: String,
|
||||
end_idx: String,
|
||||
},
|
||||
InvalidFieldValueRangeWithFreq {
|
||||
field: String,
|
||||
value: String,
|
||||
freq: ICalendarFrequency,
|
||||
start_idx: String,
|
||||
end_idx: String,
|
||||
},
|
||||
InvalidByRuleAndFrequency {
|
||||
by_rule: String,
|
||||
freq: ICalendarFrequency,
|
||||
},
|
||||
UntilBeforeStart {
|
||||
until: String,
|
||||
dt_start: String,
|
||||
},
|
||||
TooBigInterval(u16),
|
||||
StartYearOutOfRange(i32),
|
||||
UnableToGenerateTimeset,
|
||||
InvalidByRuleWithByEaster,
|
||||
DtStartUntilMismatchTimezone {
|
||||
dt_start_tz: Cow<'static, str>,
|
||||
until_tz: Cow<'static, str>,
|
||||
expected: Vec<Cow<'static, str>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ValidationError> for RRuleError {
|
||||
fn from(err: ValidationError) -> Self {
|
||||
Self::ValidationError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for RRuleError {
|
||||
fn from(err: String) -> Self {
|
||||
Self::IterError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl RRuleError {
|
||||
pub fn new_iter_err<S: AsRef<str>>(msg: S) -> Self {
|
||||
Self::IterError(msg.as_ref().to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn checked_mul_u32(v1: u32, v2: u32, hint: Option<&str>) -> Result<u32, RRuleError> {
|
||||
v1.checked_mul(v2).ok_or_else(|| match hint {
|
||||
Some(hint) => RRuleError::new_iter_err(format!(
|
||||
"Could not multiply number, would overflow (`{} * {}`), {}.",
|
||||
v1, v2, hint
|
||||
)),
|
||||
None => RRuleError::new_iter_err(format!(
|
||||
"Could not multiply number, would overflow (`{} * {}`).",
|
||||
v1, v2,
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn checked_add_u32(v1: u32, v2: u32, hint: Option<&str>) -> Result<u32, RRuleError> {
|
||||
v1.checked_add(v2).ok_or_else(|| match hint {
|
||||
Some(hint) => RRuleError::new_iter_err(format!(
|
||||
"Could not add numbers, would overflow (`{} + {}`), {}.",
|
||||
v1, v2, hint
|
||||
)),
|
||||
None => RRuleError::new_iter_err(format!(
|
||||
"Could not add numbers, would overflow (`{} + {}`).",
|
||||
v1, v2,
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
impl Display for RRuleError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RRuleError::ValidationError(err) => write!(f, "{}", err),
|
||||
RRuleError::IterError(err) => write!(f, "Iteration error: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ValidationError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ValidationError::BySetPosWithoutByRule => {
|
||||
write!(f, "BYSETPOS without BYxxx rule is not allowed")
|
||||
}
|
||||
ValidationError::InvalidFieldValue { field, value } => {
|
||||
write!(f, "Invalid value `{value}` for field `{field}`")
|
||||
}
|
||||
ValidationError::InvalidFieldValueRange {
|
||||
field,
|
||||
value,
|
||||
start_idx,
|
||||
end_idx,
|
||||
} => write!(
|
||||
f,
|
||||
"Invalid value `{value}` for field `{field}`, start index `{start_idx}` and end index `{end_idx}`"
|
||||
),
|
||||
ValidationError::InvalidFieldValueRangeWithFreq {
|
||||
field,
|
||||
value,
|
||||
freq,
|
||||
start_idx,
|
||||
end_idx,
|
||||
} => write!(
|
||||
f,
|
||||
"Invalid value `{value}` for field `{field}`, frequency `{}`, start index `{start_idx}` and end index `{end_idx}`",
|
||||
freq.as_str()
|
||||
),
|
||||
ValidationError::InvalidByRuleAndFrequency { by_rule, freq } => write!(
|
||||
f,
|
||||
"Invalid BY rule `{by_rule}` with frequency `{}`",
|
||||
freq.as_str()
|
||||
),
|
||||
ValidationError::UntilBeforeStart { until, dt_start } => write!(
|
||||
f,
|
||||
"Until date `{until}` is before the start date `{dt_start}`"
|
||||
),
|
||||
ValidationError::TooBigInterval(interval) => write!(
|
||||
f,
|
||||
"Interval of {interval} is too big. The maximum interval is 32767."
|
||||
),
|
||||
ValidationError::StartYearOutOfRange(year) => write!(
|
||||
f,
|
||||
"Start year {year} is out of range. The valid range is 1970 to 2038."
|
||||
),
|
||||
ValidationError::UnableToGenerateTimeset => {
|
||||
write!(f, "Unable to generate timeset")
|
||||
}
|
||||
ValidationError::InvalidByRuleWithByEaster => {
|
||||
write!(f, "BYEASTER cannot be used with BYxxx rules")
|
||||
}
|
||||
ValidationError::DtStartUntilMismatchTimezone {
|
||||
dt_start_tz,
|
||||
until_tz,
|
||||
expected,
|
||||
} => write!(
|
||||
f,
|
||||
"DTSTART timezone `{dt_start_tz}` does not match UNTIL timezone `{until_tz}`, expected timezones: {:?}",
|
||||
expected
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::{
|
||||
iterinfo::IterInfo,
|
||||
rrule::{NWeekday, RRule},
|
||||
};
|
||||
use crate::icalendar::ICalendarMonth;
|
||||
|
||||
type RRuleFilter = &'static dyn Fn(&IterInfo, usize, &RRule) -> bool;
|
||||
|
||||
const FILTERS: [RRuleFilter; 7] = [
|
||||
&is_filtered_by_month,
|
||||
&is_filtered_by_week_number,
|
||||
&is_filtered_by_weekday,
|
||||
&is_filtered_by_neg_weekday,
|
||||
&is_filtered_by_easter,
|
||||
&is_filtered_by_month_day,
|
||||
&is_filtered_by_year_day,
|
||||
];
|
||||
|
||||
pub(crate) fn is_filtered(ii: &IterInfo, current_day: usize) -> bool {
|
||||
let rrule = ii.rrule();
|
||||
FILTERS
|
||||
.into_iter()
|
||||
.any(|filter| filter(ii, current_day, rrule))
|
||||
}
|
||||
|
||||
fn is_filtered_by_month(ii: &IterInfo, current_day: usize, rrule: &RRule) -> bool {
|
||||
if rrule.by_month.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let current_month = ICalendarMonth::new(ii.month_mask()[current_day], false);
|
||||
!rrule.by_month.contains(¤t_month)
|
||||
}
|
||||
|
||||
fn is_filtered_by_week_number(ii: &IterInfo, current_day: usize, rrule: &RRule) -> bool {
|
||||
if rrule.by_week_no.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
matches!(ii.week_no_mask(), Some(week_no_mask) if week_no_mask[current_day] == 0)
|
||||
}
|
||||
|
||||
fn is_filtered_by_weekday(ii: &IterInfo, current_day: usize, rrule: &RRule) -> bool {
|
||||
let mut by_weekday_every_week_only = rrule
|
||||
.by_weekday
|
||||
.iter()
|
||||
.filter_map(|by_weekday| match by_weekday {
|
||||
// Get only `Every` occurrences.
|
||||
NWeekday::Every(weekday) => Some(weekday.num_days_from_monday()),
|
||||
NWeekday::Nth(_, _) => None,
|
||||
})
|
||||
.peekable();
|
||||
|
||||
// Check if empty
|
||||
if by_weekday_every_week_only.peek().is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let current_weekday = ii.weekday_mask()[current_day];
|
||||
!by_weekday_every_week_only.any(|el| el == current_weekday)
|
||||
}
|
||||
|
||||
fn is_filtered_by_neg_weekday(ii: &IterInfo, current_day: usize, _rrule: &RRule) -> bool {
|
||||
if let Some(neg_weekday_mask) = ii.neg_weekday_mask() {
|
||||
if neg_weekday_mask.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let current_neg_weekday = neg_weekday_mask[current_day];
|
||||
current_neg_weekday == 0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn is_filtered_by_easter(ii: &IterInfo, current_day: usize, rrule: &RRule) -> bool {
|
||||
if rrule.by_easter.is_none() {
|
||||
return false;
|
||||
}
|
||||
match i32::try_from(current_day) {
|
||||
Ok(current_day) => {
|
||||
!matches!(ii.easter_mask(), Some(easter_mask) if easter_mask.contains(¤t_day))
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_filtered_by_month_day(ii: &IterInfo, current_day: usize, rrule: &RRule) -> bool {
|
||||
if rrule.by_month_day.is_empty() && rrule.by_n_month_day.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let current_month_day = ii.month_day_mask()[current_day];
|
||||
let current_n_month_day = ii.neg_month_day_mask()[current_day];
|
||||
let filtered_by_month_day = !rrule.by_month_day.contains(¤t_month_day);
|
||||
let filtered_by_n_month_day = !rrule.by_n_month_day.contains(¤t_n_month_day);
|
||||
|
||||
filtered_by_month_day && filtered_by_n_month_day
|
||||
}
|
||||
|
||||
fn is_filtered_by_year_day(ii: &IterInfo, current_day: usize, rrule: &RRule) -> bool {
|
||||
if rrule.by_year_day.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let current_day = match i16::try_from(current_day) {
|
||||
Ok(current_day) => current_day,
|
||||
_ => return true,
|
||||
};
|
||||
|
||||
let year_len = (ii.year_len()) as i16;
|
||||
let next_year_len = (ii.next_year_len()) as i16;
|
||||
|
||||
if current_day < year_len {
|
||||
!rrule.by_year_day.contains(&(current_day + 1))
|
||||
&& !rrule.by_year_day.contains(&(current_day - year_len))
|
||||
} else {
|
||||
!rrule.by_year_day.contains(&(current_day + 1 - year_len))
|
||||
&& !rrule
|
||||
.by_year_day
|
||||
.contains(&(current_day - next_year_len - year_len))
|
||||
}
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::counter_date::DateTimeIter;
|
||||
use super::easter::easter;
|
||||
use super::get_month;
|
||||
use super::rrule::{NWeekday, RRule};
|
||||
use super::{monthinfo::MonthInfo, yearinfo::YearInfo};
|
||||
use crate::common::timezone::Tz;
|
||||
use crate::icalendar::ICalendarFrequency;
|
||||
use chrono::{Datelike, NaiveTime, TimeZone};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct IterInfo {
|
||||
year_info: YearInfo,
|
||||
month_info: Option<MonthInfo>,
|
||||
easter_mask: Option<Vec<i32>>,
|
||||
rrule: RRule,
|
||||
}
|
||||
|
||||
impl IterInfo {
|
||||
pub fn new(rrule: &RRule, dt_start: &chrono::DateTime<Tz>) -> Self {
|
||||
let year = dt_start.year();
|
||||
let month = get_month(dt_start);
|
||||
|
||||
let year_info = YearInfo::new(year, rrule);
|
||||
let mut ii = Self {
|
||||
rrule: rrule.clone(),
|
||||
year_info,
|
||||
month_info: None,
|
||||
easter_mask: None,
|
||||
};
|
||||
ii.rebuild_inner(year, month, true);
|
||||
|
||||
ii
|
||||
}
|
||||
|
||||
fn rebuild_inner(&mut self, year: i32, month: u8, skip_year_info: bool) {
|
||||
if !skip_year_info
|
||||
&& !matches!(&self.month_info, Some(month_info) if month_info.last_year == year)
|
||||
{
|
||||
self.year_info = YearInfo::new(year, &self.rrule);
|
||||
}
|
||||
|
||||
let contains_nth_by_weekday = self
|
||||
.rrule
|
||||
.by_weekday
|
||||
.iter()
|
||||
.any(|by_weekday| matches!(by_weekday, NWeekday::Nth(_, _)));
|
||||
|
||||
if contains_nth_by_weekday
|
||||
&& !(matches!(&self.month_info, Some(month_info) if month_info.last_month == month && month_info.last_year == year))
|
||||
{
|
||||
let new_month_info = MonthInfo::new(&self.year_info, month, &self.rrule);
|
||||
self.month_info = Some(new_month_info);
|
||||
}
|
||||
|
||||
self.easter_mask = self
|
||||
.rrule
|
||||
.by_easter
|
||||
.map(|by_easter| vec![easter(year, by_easter)]);
|
||||
}
|
||||
|
||||
pub fn rebuild(&mut self, counter_date: &DateTimeIter) {
|
||||
self.rebuild_inner(counter_date.year, counter_date.month as u8, false);
|
||||
}
|
||||
|
||||
pub fn year_len(&self) -> u16 {
|
||||
self.year_info.year_len
|
||||
}
|
||||
|
||||
pub fn year_ordinal(&self) -> i64 {
|
||||
self.year_info.year_ordinal
|
||||
}
|
||||
|
||||
pub fn month_range(&self) -> &[u16] {
|
||||
self.year_info.month_range
|
||||
}
|
||||
|
||||
pub fn easter_mask(&self) -> Option<&Vec<i32>> {
|
||||
self.easter_mask.as_ref()
|
||||
}
|
||||
|
||||
pub fn weekday_mask(&self) -> &[u32] {
|
||||
self.year_info.weekday_mask
|
||||
}
|
||||
|
||||
pub fn month_mask(&self) -> &[u8] {
|
||||
self.year_info.month_mask
|
||||
}
|
||||
|
||||
pub fn week_no_mask(&self) -> Option<&Vec<u8>> {
|
||||
self.year_info.week_no_mask.as_ref()
|
||||
}
|
||||
|
||||
pub fn neg_weekday_mask(&self) -> Option<&Vec<u8>> {
|
||||
self.month_info.as_ref().map(|info| &info.neg_weekday_mask)
|
||||
}
|
||||
|
||||
pub fn next_year_len(&self) -> u16 {
|
||||
self.year_info.next_year_len
|
||||
}
|
||||
|
||||
pub fn month_day_mask(&self) -> &[i8] {
|
||||
self.year_info.month_day_mask
|
||||
}
|
||||
|
||||
pub fn neg_month_day_mask(&self) -> &[i8] {
|
||||
self.year_info.neg_month_day_mask
|
||||
}
|
||||
|
||||
pub fn year_dayset(&self) -> Vec<usize> {
|
||||
let year_len = usize::from(self.year_len());
|
||||
(0..year_len).collect()
|
||||
}
|
||||
|
||||
pub fn month_dayset(&self, month: u32) -> Vec<usize> {
|
||||
let month_range = self.month_range();
|
||||
let month = month as usize;
|
||||
let start = usize::from(month_range[month - 1]);
|
||||
let end = usize::from(month_range[month]);
|
||||
(start..end).collect()
|
||||
}
|
||||
|
||||
pub fn weekday_set(&self, year: i32, month: u32, day: u32) -> Vec<usize> {
|
||||
let set_len = usize::from(self.year_len() + 7);
|
||||
|
||||
let mut date_ordinal = chrono::Utc
|
||||
.with_ymd_and_hms(year, month, day, 0, 0, 0)
|
||||
.unwrap()
|
||||
.ordinal0() as usize;
|
||||
|
||||
let mut set = vec![];
|
||||
|
||||
let week_start_num_days_from_monday = self.rrule.week_start.num_days_from_monday();
|
||||
|
||||
for _ in 0..7 {
|
||||
if date_ordinal >= set_len {
|
||||
break;
|
||||
}
|
||||
set.push(date_ordinal);
|
||||
date_ordinal += 1;
|
||||
if self.weekday_mask()[date_ordinal] == week_start_num_days_from_monday {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
set
|
||||
}
|
||||
|
||||
pub fn day_dayset(year: i32, month: u32, day: u32) -> Vec<usize> {
|
||||
let date_ordinal = chrono::Utc
|
||||
.with_ymd_and_hms(year, month, day, 0, 0, 0)
|
||||
.unwrap()
|
||||
.ordinal0();
|
||||
|
||||
vec![date_ordinal as usize]
|
||||
}
|
||||
|
||||
pub fn hour_timeset(&self, hour: u8) -> Vec<NaiveTime> {
|
||||
self.rrule
|
||||
.by_minute
|
||||
.iter()
|
||||
.flat_map(|minute| self.min_timeset(hour, *minute))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn min_timeset(&self, hour: u8, minute: u8) -> Vec<NaiveTime> {
|
||||
self.rrule
|
||||
.by_second
|
||||
.iter()
|
||||
.filter_map(|second| {
|
||||
NaiveTime::from_hms_opt(u32::from(hour), u32::from(minute), u32::from(*second))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn sec_timeset(hour: u8, minute: u8, second: u8) -> Vec<NaiveTime> {
|
||||
if let Some(time) =
|
||||
NaiveTime::from_hms_opt(u32::from(hour), u32::from(minute), u32::from(second))
|
||||
{
|
||||
vec![time]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_dayset(
|
||||
&self,
|
||||
freq: ICalendarFrequency,
|
||||
year: i32,
|
||||
month: u32,
|
||||
day: u32,
|
||||
) -> Vec<usize> {
|
||||
let mut dayset = match freq {
|
||||
ICalendarFrequency::Yearly => self.year_dayset(),
|
||||
ICalendarFrequency::Monthly => self.month_dayset(month),
|
||||
ICalendarFrequency::Weekly => self.weekday_set(year, month, day),
|
||||
_ => Self::day_dayset(year, month, day),
|
||||
};
|
||||
|
||||
// Filter out days according to the RRule filters.
|
||||
dayset.retain(|day| !super::filters::is_filtered(self, *day));
|
||||
|
||||
dayset
|
||||
}
|
||||
|
||||
/// Gets a timeset without checking if the hour, minute and second are valid, according
|
||||
/// to the `RRule`.
|
||||
///
|
||||
/// This is usually called after calling the `increment_counter_date` where we know
|
||||
/// that we get a valid `DateTime` back, and there is no need to do any duplicate
|
||||
/// validation.
|
||||
pub fn get_timeset_unchecked(&self, hour: u8, minute: u8, second: u8) -> Vec<NaiveTime> {
|
||||
match self.rrule.freq {
|
||||
ICalendarFrequency::Hourly => self.hour_timeset(hour),
|
||||
ICalendarFrequency::Minutely => self.min_timeset(hour, minute),
|
||||
ICalendarFrequency::Secondly => Self::sec_timeset(hour, minute, second),
|
||||
_ => unreachable!(
|
||||
"This method is never called with an invalid frequency and is not publicly exposed"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets a timeset.
|
||||
///
|
||||
/// An empty set is returned if the hour, minute and second aren't valid,
|
||||
/// according to the `RRule`.
|
||||
pub fn get_timeset(&self, hour: u8, minute: u8, second: u8) -> Vec<NaiveTime> {
|
||||
match self.rrule.freq {
|
||||
ICalendarFrequency::Hourly
|
||||
| ICalendarFrequency::Minutely
|
||||
| ICalendarFrequency::Secondly => {
|
||||
let incorrect_hour = self.rrule.freq >= ICalendarFrequency::Hourly
|
||||
&& !self.rrule.by_hour.is_empty()
|
||||
&& !self.rrule.by_hour.contains(&hour);
|
||||
let incorrect_minute = self.rrule.freq >= ICalendarFrequency::Minutely
|
||||
&& !self.rrule.by_minute.is_empty()
|
||||
&& !self.rrule.by_minute.contains(&minute);
|
||||
let incorrect_second = self.rrule.freq >= ICalendarFrequency::Secondly
|
||||
&& !self.rrule.by_second.is_empty()
|
||||
&& !self.rrule.by_second.contains(&second);
|
||||
let date_is_not_a_candidate =
|
||||
incorrect_hour || incorrect_minute || incorrect_second;
|
||||
|
||||
// If date is not a potential candidate, then we return an empty timeset.
|
||||
if date_is_not_a_candidate {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
self.get_timeset_unchecked(hour, minute, second)
|
||||
}
|
||||
_ => self
|
||||
.rrule
|
||||
.by_hour
|
||||
.iter()
|
||||
.flat_map(|hour| {
|
||||
self.rrule.by_minute.iter().flat_map(move |minute| {
|
||||
self.rrule.by_second.iter().filter_map(move |second| {
|
||||
NaiveTime::from_hms_opt(
|
||||
u32::from(*hour),
|
||||
u32::from(*minute),
|
||||
u32::from(*second),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rrule(&self) -> &RRule {
|
||||
&self.rrule
|
||||
}
|
||||
}
|
||||
Vendored
+280
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
// Every mask is 7 days longer to handle cross-year weekly periods.
|
||||
|
||||
pub(crate) type WdayMask = [u32; 7 * 55];
|
||||
pub(crate) type Mask365 = [u8; 365 + 7];
|
||||
pub(crate) type Mask366 = [u8; 366 + 7];
|
||||
pub(crate) type Mask365Signed = [i8; 365 + 7];
|
||||
pub(crate) type Mask366Signed = [i8; 366 + 7];
|
||||
pub(crate) type MonthRangeMask = [u16; 13];
|
||||
|
||||
const M365: Mask365 = [
|
||||
// 31
|
||||
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
||||
// 28
|
||||
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
|
||||
// 31
|
||||
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
|
||||
// 30
|
||||
4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
|
||||
// 31
|
||||
5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
|
||||
// 30
|
||||
6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
|
||||
// 31
|
||||
7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
|
||||
// 31
|
||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
||||
// 30
|
||||
9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9,
|
||||
// 31
|
||||
10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
|
||||
10, 10, 10, 10, 10, 10, 10,
|
||||
// 30 -----------------------------------------------------------------------------------------
|
||||
11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11,
|
||||
11, 11, 11, 11, 11, 11,
|
||||
// 31 -----------------------------------------------------------------------------------------
|
||||
12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
|
||||
12, 12, 12, 12, 12, 12, 12,
|
||||
// 7 ------------------------------------------------------------------------------------------
|
||||
1, 1, 1, 1, 1, 1, 1,
|
||||
];
|
||||
const MDAY365: Mask365Signed = [
|
||||
// 31
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31,
|
||||
// 28 -----------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31,
|
||||
// 30 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31,
|
||||
// 30 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31,
|
||||
// 30 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31,
|
||||
// 30 -----------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30,
|
||||
// 31 -----------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31,
|
||||
// 7 -----------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7,
|
||||
];
|
||||
const NMDAY365: Mask365Signed = [
|
||||
// 31
|
||||
-31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13,
|
||||
-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 28 -----------------------------------------------------------------------------------------
|
||||
-28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10,
|
||||
-9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
-31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13,
|
||||
-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 30 ---------------------------------------------------------------------------------------
|
||||
-30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12,
|
||||
-11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
-31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13,
|
||||
-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 30 ---------------------------------------------------------------------------------------
|
||||
-30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12,
|
||||
-11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
-31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13,
|
||||
-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
-31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13,
|
||||
-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 30 ---------------------------------------------------------------------------------------
|
||||
-30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12,
|
||||
-11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
-31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13,
|
||||
-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 30 -----------------------------------------------------------------------------------------
|
||||
-30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12,
|
||||
-11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 31 -----------------------------------------------------------------------------------------
|
||||
-31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13,
|
||||
-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 7 -----------------------------------------------------------------------------------------
|
||||
-31, -30, -29, -28, -27, -26, -25,
|
||||
];
|
||||
const M365_RANGE: MonthRangeMask = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365];
|
||||
|
||||
const M366: Mask366 = [
|
||||
// 31
|
||||
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
||||
// 29
|
||||
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
|
||||
// 31
|
||||
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
|
||||
// 30
|
||||
4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
|
||||
// 31
|
||||
5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
|
||||
// 30
|
||||
6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
|
||||
// 31
|
||||
7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
|
||||
// 31
|
||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
||||
// 30
|
||||
9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9,
|
||||
// 31
|
||||
10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
|
||||
10, 10, 10, 10, 10, 10, 10,
|
||||
// 30 -----------------------------------------------------------------------------------------
|
||||
11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11,
|
||||
11, 11, 11, 11, 11, 11,
|
||||
// 31 -----------------------------------------------------------------------------------------
|
||||
12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
|
||||
12, 12, 12, 12, 12, 12, 12,
|
||||
// 7 ------------------------------------------------------------------------------------------
|
||||
1, 1, 1, 1, 1, 1, 1,
|
||||
];
|
||||
const MDAY366: Mask366Signed = [
|
||||
// 31
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31,
|
||||
// 29 -----------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31,
|
||||
// 30 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31,
|
||||
// 30 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31,
|
||||
// 30 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31,
|
||||
// 30 -----------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30,
|
||||
// 31 -----------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31,
|
||||
// 7 -----------------------------------------------------------------------------------------
|
||||
1, 2, 3, 4, 5, 6, 7,
|
||||
];
|
||||
const M366_RANGE: MonthRangeMask = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366];
|
||||
const NMDAY366: Mask366Signed = [
|
||||
// 31
|
||||
-31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13,
|
||||
-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 29 -----------------------------------------------------------------------------------------
|
||||
-29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, -11,
|
||||
-10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
-31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13,
|
||||
-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 30 ---------------------------------------------------------------------------------------
|
||||
-30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12,
|
||||
-11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
-31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13,
|
||||
-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 30 ---------------------------------------------------------------------------------------
|
||||
-30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12,
|
||||
-11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
-31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13,
|
||||
-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
-31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13,
|
||||
-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 30 ---------------------------------------------------------------------------------------
|
||||
-30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12,
|
||||
-11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 31 ---------------------------------------------------------------------------------------
|
||||
-31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13,
|
||||
-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 30 -----------------------------------------------------------------------------------------
|
||||
-30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12,
|
||||
-11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 31 -----------------------------------------------------------------------------------------
|
||||
-31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13,
|
||||
-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1,
|
||||
// 7 -----------------------------------------------------------------------------------------
|
||||
-31, -30, -29, -28, -27, -26, -25,
|
||||
];
|
||||
const WDAY: WdayMask = [
|
||||
0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3,
|
||||
4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0,
|
||||
1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4,
|
||||
5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1,
|
||||
2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5,
|
||||
6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2,
|
||||
3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
|
||||
0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3,
|
||||
4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0,
|
||||
1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4,
|
||||
5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1,
|
||||
2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5,
|
||||
6,
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Masks {
|
||||
pub weekday: WdayMask,
|
||||
pub month_365: Mask365,
|
||||
pub month_365_range: MonthRangeMask,
|
||||
pub month_366: Mask366,
|
||||
pub month_366_range: MonthRangeMask,
|
||||
pub month_day_365: Mask365Signed,
|
||||
pub month_day_366: Mask366Signed,
|
||||
pub neg_month_day_365: Mask365Signed,
|
||||
pub neg_month_day_366: Mask366Signed,
|
||||
}
|
||||
|
||||
pub(crate) const MASKS: Masks = Masks {
|
||||
weekday: WDAY,
|
||||
month_365: M365,
|
||||
month_365_range: M365_RANGE,
|
||||
month_366: M366,
|
||||
month_366_range: M366_RANGE,
|
||||
month_day_365: MDAY365,
|
||||
month_day_366: MDAY366,
|
||||
neg_month_day_365: NMDAY365,
|
||||
neg_month_day_366: NMDAY366,
|
||||
};
|
||||
Vendored
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
mod checks;
|
||||
mod counter_date;
|
||||
mod easter;
|
||||
pub mod error;
|
||||
pub(crate) mod filters;
|
||||
pub(crate) mod iterinfo;
|
||||
mod masks;
|
||||
mod monthinfo;
|
||||
mod pos_list;
|
||||
pub mod rrule;
|
||||
pub(crate) mod rrule_iter;
|
||||
mod utils;
|
||||
mod validate;
|
||||
mod yearinfo;
|
||||
|
||||
use crate::common::timezone::Tz;
|
||||
use chrono::{Datelike, Duration, NaiveTime, Timelike};
|
||||
use iterinfo::IterInfo;
|
||||
use pos_list::build_pos_list;
|
||||
pub(crate) use rrule_iter::RRuleIter;
|
||||
|
||||
/// Prevent loops when searching for the next event in the iterator.
|
||||
/// If after X number of iterations it still has not found an event,
|
||||
/// we can assume it will not find an event.
|
||||
static MAX_ITER_LOOP: u32 = 100_000;
|
||||
|
||||
/// inbuxa: the work one `expand_dates` call may spend on all its recurrence
|
||||
/// rules together. Every pass over a period costs one unit, a pass that finds
|
||||
/// nothing costs one per 16 days it scanned, candidates a pass looks at
|
||||
/// without producing them cost one each, and so does every time the search
|
||||
/// for the next `BYHOUR`/`BYMINUTE`/`BYSECOND` match skips one. Rules that
|
||||
/// rarely or never match, spread over many components, can't add up past this
|
||||
/// (about 50 ms of work in a release build).
|
||||
pub(crate) const MAX_EXPANSION_WORK: u32 = 1_000_000;
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn duration_from_midnight(time: NaiveTime) -> Duration {
|
||||
Duration::hours(i64::from(time.hour()))
|
||||
+ Duration::minutes(i64::from(time.minute()))
|
||||
+ Duration::seconds(i64::from(time.second()))
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_month(dt: &chrono::DateTime<Tz>) -> u8 {
|
||||
dt.month() as u8
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_day(dt: &chrono::DateTime<Tz>) -> i8 {
|
||||
dt.day() as i8
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_hour(dt: &chrono::DateTime<Tz>) -> u8 {
|
||||
dt.hour() as u8
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_minute(dt: &chrono::DateTime<Tz>) -> u8 {
|
||||
dt.minute() as u8
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_second(dt: &chrono::DateTime<Tz>) -> u8 {
|
||||
dt.second() as u8
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::{
|
||||
rrule::{NWeekday, RRule},
|
||||
utils::pymod,
|
||||
yearinfo::YearInfo,
|
||||
};
|
||||
use crate::icalendar::ICalendarFrequency;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct MonthInfo {
|
||||
pub last_year: i32,
|
||||
pub last_month: u8,
|
||||
pub neg_weekday_mask: Vec<u8>,
|
||||
}
|
||||
|
||||
impl MonthInfo {
|
||||
pub fn new(year_info: &YearInfo, month: u8, rrule: &RRule) -> Self {
|
||||
let neg_weekday_mask = Self::get_neg_weekday_mask(year_info, month, rrule);
|
||||
Self {
|
||||
last_year: year_info.year,
|
||||
last_month: month,
|
||||
neg_weekday_mask,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_neg_weekday_mask(year_info: &YearInfo, month: u8, rrule: &RRule) -> Vec<u8> {
|
||||
let YearInfo {
|
||||
year_len,
|
||||
month_range,
|
||||
weekday_mask,
|
||||
..
|
||||
} = year_info;
|
||||
|
||||
// Build up `ranges`
|
||||
let mut ranges = vec![];
|
||||
if rrule.freq == ICalendarFrequency::Yearly {
|
||||
if rrule.by_month.is_empty() {
|
||||
ranges.push((0, u32::from(*year_len) - 1));
|
||||
} else {
|
||||
for month in &rrule.by_month {
|
||||
let month = usize::from(month.month());
|
||||
let first = u32::from(month_range[month - 1]);
|
||||
let last = u32::from(month_range[month]);
|
||||
ranges.push((first, last - 1));
|
||||
}
|
||||
}
|
||||
} else if rrule.freq == ICalendarFrequency::Monthly {
|
||||
let month = usize::from(month);
|
||||
let first = u32::from(month_range[month - 1]);
|
||||
let last = u32::from(month_range[month]);
|
||||
ranges.push((first, last - 1));
|
||||
}
|
||||
|
||||
if ranges.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Weekly frequency won't get here, so we may not
|
||||
// care about cross-year weekly periods.
|
||||
let mut neg_weekday_mask = vec![0; usize::from(*year_len)];
|
||||
|
||||
// Loop over `ranges`
|
||||
for (first, last) in ranges {
|
||||
for by_weekday in &rrule.by_weekday {
|
||||
// Only check Nth occurrences here
|
||||
if let NWeekday::Nth(number, weekday) = by_weekday {
|
||||
let weekday = (weekday.num_days_from_monday()) as i16;
|
||||
let nth_weekday = if *number < 0 {
|
||||
let number = match u32::try_from(-number) {
|
||||
Ok(num) => num,
|
||||
_ => continue,
|
||||
};
|
||||
let nth_last_week = match last.checked_sub((number - 1) * 7) {
|
||||
Some(val) => val,
|
||||
None => continue,
|
||||
};
|
||||
let index = match usize::try_from(nth_last_week) {
|
||||
Ok(i) => i,
|
||||
_ => continue,
|
||||
};
|
||||
let nth_last_weekday = match weekday_mask.get(index) {
|
||||
Some(val) => (*val) as i16,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Adjust to get the correct weekday
|
||||
let modulo = (pymod(nth_last_weekday - weekday, 7)) as u32;
|
||||
match nth_last_week.checked_sub(modulo) {
|
||||
Some(val) => val,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
let number = match u32::try_from(number - 1) {
|
||||
Ok(num) => num,
|
||||
_ => continue,
|
||||
};
|
||||
let nth_first_day = first + number * 7;
|
||||
let index = match usize::try_from(nth_first_day) {
|
||||
Ok(i) => i,
|
||||
_ => continue,
|
||||
};
|
||||
let nth_first_day_weekday = match weekday_mask.get(index) {
|
||||
Some(val) => (*val) as i16,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Adjust to get the correct weekday
|
||||
let a = (7 - nth_first_day_weekday + weekday) as u32;
|
||||
nth_first_day + pymod(a, 7)
|
||||
};
|
||||
if first <= nth_weekday
|
||||
&& nth_weekday <= last
|
||||
&& let Ok(nth_weekday) = usize::try_from(nth_weekday)
|
||||
{
|
||||
neg_weekday_mask[nth_weekday] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
neg_weekday_mask
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::timezone::Tz;
|
||||
|
||||
use super::*;
|
||||
use chrono::{TimeZone, Weekday};
|
||||
|
||||
const UTC: Tz = Tz::Tz(chrono_tz::Tz::UTC);
|
||||
|
||||
#[test]
|
||||
fn get_neg_weekday_mask_with_daily_freq() {
|
||||
let rrule = RRule {
|
||||
freq: ICalendarFrequency::Daily,
|
||||
..Default::default()
|
||||
}
|
||||
.validate(UTC.with_ymd_and_hms(1997, 1, 1, 0, 0, 0).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let year_info = YearInfo::new(1997, &rrule);
|
||||
|
||||
let neg_weekday_mask = MonthInfo::get_neg_weekday_mask(&year_info, 1, &rrule);
|
||||
assert!(neg_weekday_mask.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_neg_weekday_mask_with_yearly_freq() {
|
||||
let rrule = RRule {
|
||||
freq: ICalendarFrequency::Yearly,
|
||||
..Default::default()
|
||||
}
|
||||
.validate(UTC.with_ymd_and_hms(1997, 1, 1, 0, 0, 0).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let year_info = YearInfo::new(1997, &rrule);
|
||||
|
||||
let neg_weekday_mask = MonthInfo::get_neg_weekday_mask(&year_info, 1, &rrule);
|
||||
assert_eq!(neg_weekday_mask.len(), year_info.year_len as usize);
|
||||
assert!(neg_weekday_mask.into_iter().all(|val| val == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_neg_weekday_mask_with_yearly_freq_and_byweekday() {
|
||||
let rrule = RRule {
|
||||
freq: ICalendarFrequency::Yearly,
|
||||
by_weekday: vec![
|
||||
NWeekday::new(None, Weekday::Mon),
|
||||
NWeekday::new(Some(-2), Weekday::Thu),
|
||||
NWeekday::new(Some(1), Weekday::Thu),
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
.validate(UTC.with_ymd_and_hms(1997, 1, 1, 0, 0, 0).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let year_info = YearInfo::new(1997, &rrule);
|
||||
|
||||
let neg_weekday_mask = MonthInfo::get_neg_weekday_mask(&year_info, 1, &rrule);
|
||||
assert_eq!(neg_weekday_mask.len(), year_info.year_len as usize);
|
||||
assert!(
|
||||
neg_weekday_mask
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.all(|(idx, val)| match idx {
|
||||
1 | 351 => val == 1,
|
||||
_ => val == 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_neg_weekday_mask_with_monthly_freq_and_byweekday() {
|
||||
let rrule = RRule {
|
||||
freq: ICalendarFrequency::Monthly,
|
||||
by_weekday: vec![
|
||||
NWeekday::new(None, Weekday::Mon),
|
||||
NWeekday::new(Some(-2), Weekday::Thu),
|
||||
NWeekday::new(Some(1), Weekday::Thu),
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
.validate(UTC.with_ymd_and_hms(1997, 1, 1, 0, 0, 0).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let year_info = YearInfo::new(1997, &rrule);
|
||||
|
||||
let neg_weekday_mask = MonthInfo::get_neg_weekday_mask(&year_info, 1, &rrule);
|
||||
assert_eq!(neg_weekday_mask.len(), year_info.year_len as usize);
|
||||
assert!(
|
||||
neg_weekday_mask
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.all(|(idx, val)| match idx {
|
||||
1 | 22 => val == 1,
|
||||
_ => val == 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::utils::{add_time_to_date, date_from_ordinal, pymod};
|
||||
use crate::common::timezone::Tz;
|
||||
use chrono::NaiveTime;
|
||||
|
||||
pub(crate) fn build_pos_list(
|
||||
by_set_pos: &[i32],
|
||||
dayset: &[usize],
|
||||
timeset: &[NaiveTime],
|
||||
year_ordinal: i64,
|
||||
tz: Tz,
|
||||
) -> Vec<chrono::DateTime<Tz>> {
|
||||
let mut pos_list = vec![];
|
||||
|
||||
if timeset.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let timeset_len = (timeset.len()) as u32;
|
||||
let timeset_len_float = f64::from(timeset_len);
|
||||
let timeset_len_int = timeset_len as i32;
|
||||
for pos in by_set_pos {
|
||||
let pos = if *pos > 0 { pos - 1 } else { *pos };
|
||||
let day_pos = (f64::from(pos) / timeset_len_float).floor() as isize;
|
||||
let time_pos = (pymod(pos, timeset_len_int)) as usize;
|
||||
|
||||
let day_idx = if day_pos < 0 {
|
||||
let dayset_len = dayset.len() as isize;
|
||||
let index = dayset_len + day_pos;
|
||||
match usize::try_from(index) {
|
||||
Ok(day_idx) => day_idx,
|
||||
Err(_) => continue,
|
||||
}
|
||||
} else {
|
||||
(day_pos) as usize
|
||||
};
|
||||
let day = match dayset.get(day_idx) {
|
||||
Some(day) => day,
|
||||
None => continue,
|
||||
};
|
||||
let day = *day as i64;
|
||||
|
||||
// Get ordinal which is UTC
|
||||
let date = date_from_ordinal(year_ordinal + day);
|
||||
// Create new Date + Time combination
|
||||
// Use Time from `timeset`.
|
||||
let time = timeset[time_pos];
|
||||
let res = match add_time_to_date(tz, date, time) {
|
||||
Some(date) => date,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if !pos_list.contains(&res) {
|
||||
pos_list.push(res);
|
||||
}
|
||||
}
|
||||
|
||||
pos_list.sort();
|
||||
|
||||
pos_list
|
||||
}
|
||||
Vendored
+267
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::{
|
||||
error::{RRuleError, ValidationError},
|
||||
get_day, get_hour, get_minute, get_month, get_second,
|
||||
validate::validate_rrule_forced,
|
||||
};
|
||||
use crate::{
|
||||
common::timezone::Tz,
|
||||
icalendar::{ICalendarFrequency, ICalendarMonth, ICalendarRecurrenceRule},
|
||||
};
|
||||
use chrono::{DateTime, Datelike, Weekday};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RRule {
|
||||
pub(crate) freq: ICalendarFrequency,
|
||||
pub(crate) interval: u16,
|
||||
pub(crate) count: Option<u32>,
|
||||
pub(crate) until: Option<DateTime<Tz>>,
|
||||
pub(crate) week_start: Weekday,
|
||||
pub(crate) by_set_pos: Vec<i32>,
|
||||
pub(crate) by_month: Vec<ICalendarMonth>,
|
||||
pub(crate) by_month_day: Vec<i8>,
|
||||
pub(crate) by_n_month_day: Vec<i8>,
|
||||
pub(crate) by_year_day: Vec<i16>,
|
||||
pub(crate) by_week_no: Vec<i8>,
|
||||
pub(crate) by_weekday: Vec<NWeekday>,
|
||||
pub(crate) by_hour: Vec<u8>,
|
||||
pub(crate) by_minute: Vec<u8>,
|
||||
pub(crate) by_second: Vec<u8>,
|
||||
pub(crate) by_easter: Option<i16>,
|
||||
}
|
||||
|
||||
impl RRule {
|
||||
pub fn from_floating_ical(ical: &ICalendarRecurrenceRule) -> Option<Self> {
|
||||
Some(RRule {
|
||||
freq: ical.freq,
|
||||
interval: ical.interval.unwrap_or(1),
|
||||
count: ical.count,
|
||||
until: if let Some(until) = &ical.until {
|
||||
until.to_date_time_with_tz(Tz::Floating)?.into()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
week_start: ical.wkst.map(Into::into).unwrap_or(Weekday::Mon),
|
||||
by_set_pos: ical.bysetpos.clone(),
|
||||
by_month: ical.bymonth.clone(),
|
||||
by_month_day: ical
|
||||
.bymonthday
|
||||
.iter()
|
||||
.filter(|v| **v > 0)
|
||||
.copied()
|
||||
.collect(),
|
||||
by_n_month_day: ical
|
||||
.bymonthday
|
||||
.iter()
|
||||
.filter(|v| **v < 0)
|
||||
.copied()
|
||||
.collect(),
|
||||
by_year_day: ical.byyearday.clone(),
|
||||
by_week_no: ical.byweekno.clone(),
|
||||
by_weekday: ical
|
||||
.byday
|
||||
.iter()
|
||||
.map(|wday| NWeekday::new(wday.ordwk, wday.weekday.into()))
|
||||
.collect(),
|
||||
by_hour: ical.byhour.clone(),
|
||||
by_minute: ical.byminute.clone(),
|
||||
by_second: ical.bysecond.clone(),
|
||||
by_easter: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum NWeekday {
|
||||
Every(Weekday),
|
||||
Nth(i16, Weekday),
|
||||
}
|
||||
|
||||
impl RRule {
|
||||
pub(crate) fn finalize_parsed_rrule(mut self, dt_start: &DateTime<Tz>) -> Self {
|
||||
// Can only be set to true if the feature flag is set.
|
||||
let by_easter_is_some = self.by_easter.is_some();
|
||||
|
||||
// Add some freq-specific additional properties
|
||||
if !(!self.by_week_no.is_empty()
|
||||
|| !self.by_year_day.is_empty()
|
||||
|| !self.by_month_day.is_empty()
|
||||
|| !self.by_n_month_day.is_empty()
|
||||
|| !self.by_weekday.is_empty()
|
||||
|| by_easter_is_some)
|
||||
{
|
||||
match self.freq {
|
||||
ICalendarFrequency::Yearly => {
|
||||
if self.by_month.is_empty() {
|
||||
let month = get_month(dt_start);
|
||||
self.by_month = vec![ICalendarMonth::new(month, false)];
|
||||
}
|
||||
let day = get_day(dt_start);
|
||||
self.by_month_day = vec![day];
|
||||
}
|
||||
ICalendarFrequency::Monthly => {
|
||||
let day = get_day(dt_start);
|
||||
self.by_month_day = vec![day];
|
||||
}
|
||||
ICalendarFrequency::Weekly => {
|
||||
self.by_weekday = vec![NWeekday::Every(dt_start.weekday())];
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
}
|
||||
|
||||
// by_hour
|
||||
if self.by_hour.is_empty() && self.freq < ICalendarFrequency::Hourly {
|
||||
let hour = get_hour(dt_start);
|
||||
self.by_hour = vec![hour];
|
||||
}
|
||||
|
||||
// by_minute
|
||||
if self.by_minute.is_empty() && self.freq < ICalendarFrequency::Minutely {
|
||||
let minute = get_minute(dt_start);
|
||||
self.by_minute = vec![minute];
|
||||
}
|
||||
|
||||
// by_second
|
||||
if self.by_second.is_empty() && self.freq < ICalendarFrequency::Secondly {
|
||||
let second = get_second(dt_start);
|
||||
self.by_second = vec![second];
|
||||
}
|
||||
|
||||
// make sure all BYXXX are unique and sorted
|
||||
self.by_hour.sort_unstable();
|
||||
self.by_hour.dedup();
|
||||
|
||||
self.by_minute.sort_unstable();
|
||||
self.by_minute.dedup();
|
||||
|
||||
self.by_second.sort_unstable();
|
||||
self.by_second.dedup();
|
||||
|
||||
self.by_month.sort_unstable();
|
||||
self.by_month.dedup();
|
||||
|
||||
self.by_month_day.sort_unstable();
|
||||
self.by_month_day.dedup();
|
||||
|
||||
self.by_n_month_day.sort_unstable();
|
||||
self.by_n_month_day.dedup();
|
||||
|
||||
self.by_year_day.sort_unstable();
|
||||
self.by_year_day.dedup();
|
||||
|
||||
self.by_week_no.sort_unstable();
|
||||
self.by_week_no.dedup();
|
||||
|
||||
self.by_set_pos.sort_unstable();
|
||||
self.by_set_pos.dedup();
|
||||
|
||||
self.by_weekday.sort_unstable();
|
||||
self.by_weekday.dedup();
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn validate(self, dt_start: DateTime<Tz>) -> Result<RRule, RRuleError> {
|
||||
let rrule = self.finalize_parsed_rrule(&dt_start);
|
||||
|
||||
// Validate required checks (defined by RFC 5545)
|
||||
validate_rrule_forced(&rrule, &dt_start)?;
|
||||
|
||||
// Check if it is possible to generate a timeset
|
||||
match rrule.freq {
|
||||
ICalendarFrequency::Hourly => {
|
||||
if rrule.by_minute.is_empty() && rrule.by_second.is_empty() {
|
||||
return Err(ValidationError::UnableToGenerateTimeset.into());
|
||||
}
|
||||
}
|
||||
ICalendarFrequency::Minutely => {
|
||||
if rrule.by_second.is_empty() {
|
||||
return Err(ValidationError::UnableToGenerateTimeset.into());
|
||||
}
|
||||
}
|
||||
ICalendarFrequency::Secondly => {}
|
||||
_ => {
|
||||
if rrule.by_hour.is_empty()
|
||||
&& rrule.by_minute.is_empty()
|
||||
&& rrule.by_second.is_empty()
|
||||
{
|
||||
return Err(ValidationError::UnableToGenerateTimeset.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(rrule)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RRule {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
freq: ICalendarFrequency::Yearly,
|
||||
interval: 1,
|
||||
count: None,
|
||||
until: None,
|
||||
week_start: Weekday::Mon,
|
||||
by_set_pos: Vec::new(),
|
||||
by_month: Vec::new(),
|
||||
by_month_day: Vec::new(),
|
||||
by_n_month_day: Vec::new(),
|
||||
by_year_day: Vec::new(),
|
||||
by_week_no: Vec::new(),
|
||||
by_weekday: Vec::new(),
|
||||
by_hour: Vec::new(),
|
||||
by_minute: Vec::new(),
|
||||
by_second: Vec::new(),
|
||||
by_easter: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for NWeekday {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for NWeekday {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
n_weekday_cmp(*self, *other)
|
||||
}
|
||||
}
|
||||
|
||||
// The ordering here doesn't really matter as it is only used to sort for display purposes
|
||||
fn n_weekday_cmp(val1: NWeekday, val2: NWeekday) -> Ordering {
|
||||
match val1 {
|
||||
NWeekday::Every(wday) => match val2 {
|
||||
NWeekday::Every(other_wday) => wday
|
||||
.num_days_from_monday()
|
||||
.cmp(&other_wday.num_days_from_monday()),
|
||||
NWeekday::Nth(_n, _other_wday) => Ordering::Less,
|
||||
},
|
||||
NWeekday::Nth(n, wday) => match val2 {
|
||||
NWeekday::Every(_) => Ordering::Greater,
|
||||
NWeekday::Nth(other_n, other_wday) => match n.cmp(&other_n) {
|
||||
Ordering::Equal => wday
|
||||
.num_days_from_monday()
|
||||
.cmp(&other_wday.num_days_from_monday()),
|
||||
less_or_greater => less_or_greater,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl NWeekday {
|
||||
pub fn new(number: Option<i16>, weekday: Weekday) -> Self {
|
||||
match number {
|
||||
Some(number) => Self::Nth(number, weekday),
|
||||
None => Self::Every(weekday),
|
||||
}
|
||||
}
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::counter_date::DateTimeIter;
|
||||
use super::rrule::RRule;
|
||||
use super::utils::add_time_to_date;
|
||||
use super::{IterInfo, MAX_ITER_LOOP, build_pos_list, utils::date_from_ordinal};
|
||||
use super::{get_hour, get_minute, get_second};
|
||||
use crate::common::timezone::Tz;
|
||||
use crate::icalendar::ICalendarFrequency;
|
||||
use chrono::NaiveTime;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
// inbuxa: passes plus skipped times, counted for the expansion budget tests
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
pub(crate) static ITERATIONS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RRuleIter {
|
||||
pub(crate) counter_date: DateTimeIter,
|
||||
pub(crate) ii: IterInfo,
|
||||
pub(crate) timeset: Vec<NaiveTime>,
|
||||
pub(crate) dt_start: chrono::DateTime<Tz>,
|
||||
pub(crate) buffer: VecDeque<chrono::DateTime<Tz>>,
|
||||
pub(crate) finished: bool,
|
||||
pub(crate) count: Option<u32>,
|
||||
pub(crate) limited: bool,
|
||||
pub(crate) was_limited: bool,
|
||||
// inbuxa: work left from the expansion budget, see `MAX_EXPANSION_WORK`
|
||||
pub(crate) budget: u32,
|
||||
pub(crate) budget_exhausted: bool,
|
||||
}
|
||||
|
||||
impl RRuleIter {
|
||||
pub(crate) fn new(rrule: &RRule, dt_start: &chrono::DateTime<Tz>, limited: bool) -> Self {
|
||||
let ii = IterInfo::new(rrule, dt_start);
|
||||
|
||||
let hour = get_hour(dt_start);
|
||||
let minute = get_minute(dt_start);
|
||||
let second = get_second(dt_start);
|
||||
let timeset = ii.get_timeset(hour, minute, second);
|
||||
let count = ii.rrule().count;
|
||||
|
||||
Self {
|
||||
counter_date: dt_start.into(),
|
||||
ii,
|
||||
timeset,
|
||||
dt_start: *dt_start,
|
||||
buffer: VecDeque::new(),
|
||||
finished: false,
|
||||
count,
|
||||
limited,
|
||||
was_limited: false,
|
||||
budget: u32::MAX,
|
||||
budget_exhausted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// inbuxa: limits the work this iterator may spend to `budget` units; once
|
||||
/// it runs out, the iterator ends and `budget_exhausted` is set.
|
||||
pub(crate) fn with_budget(mut self, budget: u32) -> Self {
|
||||
self.budget = budget;
|
||||
self
|
||||
}
|
||||
|
||||
/// Attempts to add a date to the result. Returns `true` if we should
|
||||
/// terminate the iteration.
|
||||
fn try_add_datetime(
|
||||
dt: chrono::DateTime<Tz>,
|
||||
rrule: &RRule,
|
||||
count: &mut Option<u32>,
|
||||
buffer: &mut VecDeque<chrono::DateTime<Tz>>,
|
||||
dt_start: &chrono::DateTime<Tz>,
|
||||
) -> bool {
|
||||
if matches!(rrule.until, Some(until) if dt > until) {
|
||||
// We can break because `pos_list` is sorted and
|
||||
// all the next dates will only be larger than `until`.
|
||||
return true;
|
||||
}
|
||||
|
||||
if dt >= *dt_start {
|
||||
buffer.push_back(dt);
|
||||
|
||||
if let Some(count) = count {
|
||||
*count -= 1;
|
||||
|
||||
if *count == 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Generates a list of dates that will be added to the buffer.
|
||||
/// Returns true if finished, no more items should/can be returned.
|
||||
fn generate(&mut self) -> bool {
|
||||
// Do early check if done (if known)
|
||||
if self.finished {
|
||||
return true;
|
||||
}
|
||||
// Check if the count is set, and if 0
|
||||
if matches!(self.count, Some(count) if count == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let rrule = self.ii.rrule();
|
||||
|
||||
if rrule.interval == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut loop_counter: u32 = 0;
|
||||
// Loop until there is at least 1 item in the buffer.
|
||||
while self.buffer.is_empty() {
|
||||
// Prevent infinite loops
|
||||
if self.limited {
|
||||
loop_counter += 1;
|
||||
if loop_counter >= MAX_ITER_LOOP {
|
||||
self.finished = true;
|
||||
self.was_limited = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let rrule = self.ii.rrule();
|
||||
|
||||
let dayset = self.ii.get_dayset(
|
||||
rrule.freq,
|
||||
self.counter_date.year,
|
||||
self.counter_date.month,
|
||||
self.counter_date.day,
|
||||
);
|
||||
|
||||
let tz = self.dt_start.timezone();
|
||||
|
||||
if rrule.by_set_pos.is_empty() {
|
||||
// Loop over `start..end`
|
||||
for current_day in &dayset {
|
||||
let current_day = *current_day as i64;
|
||||
let year_ordinal = self.ii.year_ordinal();
|
||||
// Ordinal conversion uses UTC: if we apply local-TZ here, then
|
||||
// just below we'll end up double-applying.
|
||||
let date = date_from_ordinal(year_ordinal + current_day);
|
||||
for time in &self.timeset {
|
||||
let Some(dt) = add_time_to_date(tz, date, *time) else {
|
||||
continue;
|
||||
};
|
||||
if Self::try_add_datetime(
|
||||
dt,
|
||||
rrule,
|
||||
&mut self.count,
|
||||
&mut self.buffer,
|
||||
&self.dt_start,
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let pos_list = build_pos_list(
|
||||
&rrule.by_set_pos,
|
||||
&dayset,
|
||||
&self.timeset,
|
||||
self.ii.year_ordinal(),
|
||||
self.dt_start.timezone(),
|
||||
);
|
||||
for dt in pos_list {
|
||||
if Self::try_add_datetime(
|
||||
dt,
|
||||
rrule,
|
||||
&mut self.count,
|
||||
&mut self.buffer,
|
||||
&self.dt_start,
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: what this pass cost; the buffer was empty when it began
|
||||
let produced = self.buffer.len();
|
||||
let candidates = dayset.len().saturating_mul(self.timeset.len());
|
||||
let mut work = if produced == 0 {
|
||||
let days = match rrule.freq {
|
||||
ICalendarFrequency::Yearly => usize::from(self.ii.year_len()),
|
||||
ICalendarFrequency::Monthly => 31,
|
||||
ICalendarFrequency::Weekly => 7,
|
||||
_ => 1,
|
||||
};
|
||||
// one unit per 16 days scanned keeps a unit near the cost of a
|
||||
// daily pass
|
||||
days.div_ceil(16).saturating_add(candidates)
|
||||
} else {
|
||||
1 + candidates.saturating_sub(produced)
|
||||
};
|
||||
|
||||
let increment_day = dayset.is_empty();
|
||||
let mut steps = 0;
|
||||
let incremented = self
|
||||
.counter_date
|
||||
.increment_counted(rrule, increment_day, &mut steps);
|
||||
work = work.saturating_add(steps as usize);
|
||||
#[cfg(test)]
|
||||
ITERATIONS.with(|count| count.set(count.get() + 1 + u64::from(steps)));
|
||||
let Some(budget) = self
|
||||
.budget
|
||||
.checked_sub(u32::try_from(work).unwrap_or(u32::MAX))
|
||||
else {
|
||||
self.budget = 0;
|
||||
self.budget_exhausted = true;
|
||||
self.finished = true;
|
||||
return true;
|
||||
};
|
||||
self.budget = budget;
|
||||
if incremented.is_err() {
|
||||
self.finished = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
rrule.freq,
|
||||
ICalendarFrequency::Hourly
|
||||
| ICalendarFrequency::Minutely
|
||||
| ICalendarFrequency::Secondly
|
||||
) {
|
||||
let hour = self.counter_date.hour as u8;
|
||||
let minute = self.counter_date.minute as u8;
|
||||
let second = self.counter_date.second as u8;
|
||||
self.timeset = self.ii.get_timeset_unchecked(hour, minute, second);
|
||||
}
|
||||
|
||||
self.ii.rebuild(&self.counter_date);
|
||||
}
|
||||
|
||||
// Indicate that there might be more items on the next iteration.
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for RRuleIter {
|
||||
type Item = chrono::DateTime<Tz>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if !self.buffer.is_empty() {
|
||||
return self.buffer.pop_front();
|
||||
}
|
||||
|
||||
if self.finished {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.finished = self.generate();
|
||||
|
||||
if self.buffer.is_empty() {
|
||||
self.finished = true;
|
||||
}
|
||||
self.buffer.pop_front()
|
||||
}
|
||||
}
|
||||
Vendored
+194
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::duration_from_midnight;
|
||||
use crate::common::timezone::Tz;
|
||||
use chrono::{NaiveDate, NaiveTime, Utc};
|
||||
use std::ops;
|
||||
|
||||
const DAY_SECS: i64 = 24 * 60 * 60;
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn date_from_ordinal(ordinal: i64) -> NaiveDate {
|
||||
chrono::DateTime::<Utc>::from_timestamp(ordinal * DAY_SECS, 0)
|
||||
.unwrap_or_default()
|
||||
.date_naive()
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn days_since_unix_epoch(date: &chrono::DateTime<Utc>) -> i64 {
|
||||
date.timestamp() / DAY_SECS
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn is_leap_year(year: i32) -> bool {
|
||||
year.trailing_zeros() >= 2 && (year % 25 != 0 || year.trailing_zeros() >= 4)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_year_len(year: i32) -> u16 {
|
||||
if is_leap_year(year) { 366 } else { 365 }
|
||||
}
|
||||
|
||||
pub(crate) trait DifferentSigns {
|
||||
fn different_sign(a: Self, b: Self) -> bool;
|
||||
}
|
||||
|
||||
macro_rules! impl_different_signs {
|
||||
($($ty:ty),*) => {
|
||||
$(
|
||||
impl DifferentSigns for $ty {
|
||||
fn different_sign(a: Self, b: Self) -> bool {
|
||||
a > 0 && b < 0 || a < 0 && b > 0
|
||||
}
|
||||
})*
|
||||
};
|
||||
(@unsigned, $($ty:ty),*) => {
|
||||
$(
|
||||
impl DifferentSigns for $ty {
|
||||
fn different_sign(_: Self, _: Self) -> bool {
|
||||
false
|
||||
}
|
||||
})*
|
||||
};
|
||||
}
|
||||
impl_different_signs!(isize, i32, i16);
|
||||
impl_different_signs!(@unsigned, usize, u32, u16, u8);
|
||||
|
||||
pub(crate) fn pymod<T>(a: T, b: T) -> T
|
||||
where
|
||||
T: DifferentSigns + Copy + ops::Rem<Output = T> + ops::Add<Output = T>,
|
||||
{
|
||||
let r = a % b;
|
||||
// If r and b differ in sign, add b to wrap the result to the correct sign.
|
||||
if T::different_sign(r, b) { r + b } else { r }
|
||||
}
|
||||
|
||||
pub(crate) fn add_time_to_date(
|
||||
tz: Tz,
|
||||
date: NaiveDate,
|
||||
time: NaiveTime,
|
||||
) -> Option<chrono::DateTime<Tz>> {
|
||||
if let Some(dt) = date.and_time(time).and_local_timezone(tz).single() {
|
||||
return Some(dt);
|
||||
}
|
||||
// If the day is a daylight saving time, the above code might not work, and we
|
||||
// can try to get a valid datetime by adding the `time` as a duration instead.
|
||||
let dt = date.and_hms_opt(0, 0, 0)?.and_local_timezone(tz).single()?;
|
||||
let day_duration = duration_from_midnight(time);
|
||||
dt.checked_add_signed(day_duration)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use chrono::{Duration, TimeZone};
|
||||
|
||||
#[test]
|
||||
fn naive_date_from_ordinal() {
|
||||
let tests = [
|
||||
(-1, NaiveDate::from_ymd_opt(1969, 12, 31).unwrap()),
|
||||
(0, NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()),
|
||||
(1, NaiveDate::from_ymd_opt(1970, 1, 2).unwrap()),
|
||||
(10, NaiveDate::from_ymd_opt(1970, 1, 11).unwrap()),
|
||||
(365, NaiveDate::from_ymd_opt(1971, 1, 1).unwrap()),
|
||||
(19877, NaiveDate::from_ymd_opt(2024, 6, 3).unwrap()),
|
||||
];
|
||||
|
||||
for (days, expected) in tests {
|
||||
assert_eq!(date_from_ordinal(days), expected, "seconds: {}", days);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_mod() {
|
||||
assert_eq!(pymod(2, -3), -1);
|
||||
assert_eq!(pymod(-2, 3), 1);
|
||||
assert_eq!(pymod(-2, -3), -2);
|
||||
assert_eq!(pymod(-3, -3), 0);
|
||||
assert_eq!(pymod(0, 3), 0);
|
||||
assert_eq!(pymod(1, 3), 1);
|
||||
assert_eq!(pymod(2, 3), 2);
|
||||
assert_eq!(pymod(3, 3), 0);
|
||||
assert_eq!(pymod(4, 3), 1);
|
||||
assert_eq!(pymod(6, 3), 0);
|
||||
assert_eq!(pymod(-6, 3), 0);
|
||||
assert_eq!(pymod(-6, -3), 0);
|
||||
assert_eq!(pymod(6, -3), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leap_year() {
|
||||
let tests = [
|
||||
(2015, false),
|
||||
(2016, true),
|
||||
(2017, false),
|
||||
(2018, false),
|
||||
(2019, false),
|
||||
(2020, true),
|
||||
(2021, false),
|
||||
];
|
||||
|
||||
for (year, expected_output) in tests {
|
||||
let res = is_leap_year(year);
|
||||
assert_eq!(res, expected_output);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn year_length() {
|
||||
let tests = [(2015, 365), (2016, 366)];
|
||||
|
||||
for (year, expected_output) in tests {
|
||||
let res = get_year_len(year);
|
||||
assert_eq!(res, expected_output);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adds_time_to_date() {
|
||||
let tests = [
|
||||
(
|
||||
Tz::Tz(chrono_tz::Tz::UTC),
|
||||
NaiveDate::from_ymd_opt(2017, 1, 1).unwrap(),
|
||||
NaiveTime::from_hms_opt(1, 15, 30).unwrap(),
|
||||
Some(
|
||||
Tz::Tz(chrono_tz::Tz::UTC)
|
||||
.with_ymd_and_hms(2017, 1, 1, 1, 15, 30)
|
||||
.unwrap(),
|
||||
),
|
||||
),
|
||||
(
|
||||
Tz::Tz(chrono_tz::Tz::America__Vancouver),
|
||||
NaiveDate::from_ymd_opt(2021, 3, 14).unwrap(),
|
||||
NaiveTime::from_hms_opt(2, 22, 10).unwrap(),
|
||||
Some(
|
||||
Tz::Tz(chrono_tz::Tz::America__Vancouver)
|
||||
.with_ymd_and_hms(2021, 3, 14, 0, 0, 0)
|
||||
.unwrap()
|
||||
+ Duration::hours(2)
|
||||
+ Duration::minutes(22)
|
||||
+ Duration::seconds(10),
|
||||
),
|
||||
),
|
||||
(
|
||||
Tz::Tz(chrono_tz::Tz::America__New_York),
|
||||
NaiveDate::from_ymd_opt(1997, 10, 26).unwrap(),
|
||||
NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
|
||||
Some(
|
||||
Tz::Tz(chrono_tz::Tz::America__New_York)
|
||||
.with_ymd_and_hms(1997, 10, 26, 9, 0, 0)
|
||||
.unwrap(),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
for (tz, date, time, expected_output) in tests {
|
||||
let res = add_time_to_date(tz, date, time);
|
||||
assert_eq!(res, expected_output);
|
||||
}
|
||||
}
|
||||
}
|
||||
+647
@@ -0,0 +1,647 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::{
|
||||
error::ValidationError,
|
||||
rrule::{NWeekday, RRule},
|
||||
};
|
||||
use crate::{common::timezone::Tz, icalendar::ICalendarFrequency};
|
||||
use std::{borrow::Cow, ops::RangeInclusive};
|
||||
|
||||
pub(crate) static MONTH_RANGE: RangeInclusive<u8> = 1..=12;
|
||||
pub(crate) static YEAR_RANGE: RangeInclusive<i32> = -10_000..=10_000;
|
||||
|
||||
type Validator = &'static dyn Fn(&RRule, &chrono::DateTime<Tz>) -> Result<(), ValidationError>;
|
||||
|
||||
const VALIDATION_PIPELINE: [Validator; 11] = [
|
||||
&validate_until,
|
||||
&validate_by_set_pos,
|
||||
&validate_by_month,
|
||||
&validate_by_month_day,
|
||||
&validate_by_year_day,
|
||||
&validate_by_week_number,
|
||||
&validate_by_weekday,
|
||||
&validate_by_hour,
|
||||
&validate_by_minute,
|
||||
&validate_by_second,
|
||||
&validate_by_easter,
|
||||
];
|
||||
|
||||
/// Check if rules are valid as defined by the RFC and crate limitations.
|
||||
/// It checks all values in the [`RRule`] and makes sure that they are in
|
||||
/// the accepted ranges. If the function returns `Ok`, no errors where found.
|
||||
///
|
||||
/// This check should always be done and just enforces limits set by the standard.
|
||||
/// Validation will always be enforced and can not be disabled using feature flags.
|
||||
pub(crate) fn validate_rrule_forced(
|
||||
rrule: &RRule,
|
||||
dt_start: &chrono::DateTime<Tz>,
|
||||
) -> Result<(), ValidationError> {
|
||||
VALIDATION_PIPELINE
|
||||
.into_iter()
|
||||
.try_for_each(|validator| validator(rrule, dt_start))
|
||||
}
|
||||
|
||||
// Until:
|
||||
// - Timezones are correctly synced as specified in the RFC
|
||||
// - Value should be later than `dt_start`.
|
||||
fn validate_until(rrule: &RRule, dt_start: &chrono::DateTime<Tz>) -> Result<(), ValidationError> {
|
||||
match rrule.until {
|
||||
Some(until) => {
|
||||
match dt_start.timezone() {
|
||||
Tz::Tz(_) => {
|
||||
if until.timezone() != Tz::UTC {
|
||||
return Err(ValidationError::DtStartUntilMismatchTimezone {
|
||||
dt_start_tz: dt_start
|
||||
.timezone()
|
||||
.name()
|
||||
.unwrap_or(Cow::Borrowed("Floating")),
|
||||
until_tz: until.timezone().name().unwrap_or(Cow::Borrowed("Floating")),
|
||||
expected: vec!["UTC".into()],
|
||||
});
|
||||
}
|
||||
}
|
||||
v => {
|
||||
let allowed_timezones = vec![v, Tz::UTC];
|
||||
if !allowed_timezones.contains(&until.timezone()) {
|
||||
return Err(ValidationError::DtStartUntilMismatchTimezone {
|
||||
dt_start_tz: dt_start
|
||||
.timezone()
|
||||
.name()
|
||||
.unwrap_or(Cow::Borrowed("Floating")),
|
||||
until_tz: until.timezone().name().unwrap_or(Cow::Borrowed("Floating")),
|
||||
expected: allowed_timezones
|
||||
.into_iter()
|
||||
.map(|tz| tz.name().unwrap_or(Cow::Borrowed("Floating")))
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if until < *dt_start {
|
||||
return Err(ValidationError::UntilBeforeStart {
|
||||
until: until.to_rfc3339(),
|
||||
dt_start: dt_start.to_rfc3339(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
// By_set_pos:
|
||||
// - Can be a value from -366 to -1 and 1 to 366 depending on `freq`
|
||||
fn validate_by_set_pos(
|
||||
rrule: &RRule,
|
||||
_dt_start: &chrono::DateTime<Tz>,
|
||||
) -> Result<(), ValidationError> {
|
||||
validate_not_equal_for_vec(&0, &rrule.by_set_pos, "BYSETPOS")?;
|
||||
let range = match rrule.freq {
|
||||
ICalendarFrequency::Yearly | ICalendarFrequency::Daily => -366..=366, // TODO is the daily range correct?
|
||||
ICalendarFrequency::Monthly => -31..=31,
|
||||
ICalendarFrequency::Weekly => -53..=53,
|
||||
ICalendarFrequency::Hourly => -24..=24,
|
||||
ICalendarFrequency::Minutely | ICalendarFrequency::Secondly => -60..=60,
|
||||
};
|
||||
if let Err(value) = validate_range_for_vec_error(&range, &rrule.by_set_pos) {
|
||||
return Err(ValidationError::InvalidFieldValueRangeWithFreq {
|
||||
field: "BYSETPOS".into(),
|
||||
value: value.to_string(),
|
||||
freq: rrule.freq,
|
||||
start_idx: range.start().to_string(),
|
||||
end_idx: range.end().to_string(),
|
||||
});
|
||||
}
|
||||
// - It MUST only be used in conjunction with another BYxxx rule part.
|
||||
if !rrule.by_set_pos.is_empty()
|
||||
&& rrule.by_easter.is_none()
|
||||
&& rrule.by_hour.is_empty()
|
||||
&& rrule.by_minute.is_empty()
|
||||
&& rrule.by_second.is_empty()
|
||||
&& rrule.by_month_day.is_empty()
|
||||
&& rrule.by_month.is_empty()
|
||||
&& rrule.by_year_day.is_empty()
|
||||
&& rrule.by_week_no.is_empty()
|
||||
&& rrule.by_weekday.is_empty()
|
||||
{
|
||||
return Err(ValidationError::BySetPosWithoutByRule);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// By_month:
|
||||
// - Can be a value from 1 to 12.
|
||||
fn validate_by_month(
|
||||
rrule: &RRule,
|
||||
_dt_start: &chrono::DateTime<Tz>,
|
||||
) -> Result<(), ValidationError> {
|
||||
for item in &rrule.by_month {
|
||||
if !MONTH_RANGE.contains(&item.month()) {
|
||||
return Err(ValidationError::InvalidFieldValueRange {
|
||||
field: "BYMONTH".to_string(),
|
||||
value: item.to_string(),
|
||||
start_idx: 1.to_string(),
|
||||
end_idx: 12.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// By_month_day:
|
||||
// - Can be a value from -31 to -1 and 1 to 31.
|
||||
fn validate_by_month_day(
|
||||
rrule: &RRule,
|
||||
_dt_start: &chrono::DateTime<Tz>,
|
||||
) -> Result<(), ValidationError> {
|
||||
validate_not_equal_for_vec(&0, &rrule.by_month_day, "BYMONTHDAY")?;
|
||||
validate_range_for_vec(&(-31..=31), &rrule.by_month_day, "BYMONTHDAY")?;
|
||||
// - MUST NOT be specified when the FREQ rule part is set to WEEKLY.
|
||||
// Validated below
|
||||
if !rrule.by_month_day.is_empty() {
|
||||
let valid = rrule.freq != ICalendarFrequency::Weekly;
|
||||
if !valid {
|
||||
return Err(ValidationError::InvalidByRuleAndFrequency {
|
||||
by_rule: "BYMONTHDAY".into(),
|
||||
freq: rrule.freq,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// By_year_day:
|
||||
// - Can be a value from -366 to -1 and 1 to 366.
|
||||
fn validate_by_year_day(
|
||||
rrule: &RRule,
|
||||
_dt_start: &chrono::DateTime<Tz>,
|
||||
) -> Result<(), ValidationError> {
|
||||
validate_not_equal_for_vec(&0, &rrule.by_year_day, "BYYEARDAY")?;
|
||||
validate_range_for_vec(&(-366..=366), &rrule.by_year_day, "BYYEARDAY")?;
|
||||
// - MUST NOT be specified when the FREQ rule part is set to DAILY, WEEKLY, or MONTHLY.
|
||||
// Validated below
|
||||
if !rrule.by_year_day.is_empty() {
|
||||
let valid = !matches!(
|
||||
rrule.freq,
|
||||
ICalendarFrequency::Monthly | ICalendarFrequency::Weekly | ICalendarFrequency::Daily
|
||||
);
|
||||
if !valid {
|
||||
return Err(ValidationError::InvalidByRuleAndFrequency {
|
||||
by_rule: "BYYEARDAY".into(),
|
||||
freq: rrule.freq,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// By_week_no:
|
||||
// - Can be a value from -53 to -1 and 1 to 53.
|
||||
fn validate_by_week_number(
|
||||
rrule: &RRule,
|
||||
_dt_start: &chrono::DateTime<Tz>,
|
||||
) -> Result<(), ValidationError> {
|
||||
validate_not_equal_for_vec(&0, &rrule.by_week_no, "BYWEEKNO")?;
|
||||
validate_range_for_vec(&(-53..=53), &rrule.by_week_no, "BYWEEKNO")?;
|
||||
// - MUST NOT be used when the FREQ rule part is set to anything other than YEARLY.
|
||||
// Validated below
|
||||
if !rrule.by_week_no.is_empty() {
|
||||
let valid = rrule.freq == ICalendarFrequency::Yearly;
|
||||
if !valid {
|
||||
return Err(ValidationError::InvalidByRuleAndFrequency {
|
||||
by_rule: "BYWEEKNO".into(),
|
||||
freq: rrule.freq,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// By_weekday:
|
||||
// - Check if value for `Nth` is within range.
|
||||
// The Range depends on frequency and can only happen weekly, so `/7` from normal count.
|
||||
fn validate_by_weekday(
|
||||
rrule: &RRule,
|
||||
_dt_start: &chrono::DateTime<Tz>,
|
||||
) -> Result<(), ValidationError> {
|
||||
let range = match rrule.freq {
|
||||
ICalendarFrequency::Yearly | ICalendarFrequency::Daily => (-366 / 7)..=(366 / 7 + 1), // TODO is the daily range correct?
|
||||
ICalendarFrequency::Monthly => (-31 / 7)..=(31 / 7 + 1),
|
||||
ICalendarFrequency::Weekly => (-53 / 7)..=(53 / 7 + 1),
|
||||
ICalendarFrequency::Hourly => (-24 / 7)..=(24 / 7 + 1),
|
||||
ICalendarFrequency::Minutely | ICalendarFrequency::Secondly => (-60 / 7)..=(60 / 7 + 1),
|
||||
};
|
||||
for item in &rrule.by_weekday {
|
||||
if let NWeekday::Nth(number, _weekday) = item {
|
||||
// If value not in range = error
|
||||
if !range.contains(number) {
|
||||
return Err(ValidationError::InvalidFieldValueRangeWithFreq {
|
||||
field: "BYDAY".into(),
|
||||
value: number.to_string(),
|
||||
freq: rrule.freq,
|
||||
start_idx: range.start().to_string(),
|
||||
end_idx: range.end().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// By_hour:
|
||||
// - Can be a value from 0 to 23.
|
||||
fn validate_by_hour(
|
||||
rrule: &RRule,
|
||||
_dt_start: &chrono::DateTime<Tz>,
|
||||
) -> Result<(), ValidationError> {
|
||||
validate_range_for_vec(&(0..=23), &rrule.by_hour, "BYHOUR")
|
||||
}
|
||||
|
||||
// By_minute:
|
||||
// - Can be a value from 0 to 59.
|
||||
fn validate_by_minute(
|
||||
rrule: &RRule,
|
||||
_dt_start: &chrono::DateTime<Tz>,
|
||||
) -> Result<(), ValidationError> {
|
||||
validate_range_for_vec(&(0..=59), &rrule.by_minute, "BYMINUTE")
|
||||
}
|
||||
|
||||
// By_second:
|
||||
// - Can be a value from 0 to 59.
|
||||
fn validate_by_second(
|
||||
rrule: &RRule,
|
||||
_dt_start: &chrono::DateTime<Tz>,
|
||||
) -> Result<(), ValidationError> {
|
||||
validate_range_for_vec(&(0..=59), &rrule.by_second, "BYSECOND")
|
||||
}
|
||||
|
||||
fn validate_by_easter(
|
||||
rrule: &RRule,
|
||||
_dt_start: &chrono::DateTime<Tz>,
|
||||
) -> Result<(), ValidationError> {
|
||||
// By_easter:
|
||||
// - Can be a value from -366 to 366.
|
||||
// Validated below
|
||||
if let Some(by_easter) = &rrule.by_easter {
|
||||
validate_range_for_vec(&(-366..=366), &[*by_easter], "BYEASTER")?;
|
||||
}
|
||||
// - Can only be used on frequency: Yearly, Monthly, Daily
|
||||
// Validated below
|
||||
if rrule.by_easter.is_some() {
|
||||
let valid = matches!(
|
||||
rrule.freq,
|
||||
ICalendarFrequency::Yearly | ICalendarFrequency::Monthly | ICalendarFrequency::Daily
|
||||
);
|
||||
if !valid {
|
||||
return Err(ValidationError::InvalidByRuleAndFrequency {
|
||||
by_rule: "BYEASTER".into(),
|
||||
freq: rrule.freq,
|
||||
});
|
||||
}
|
||||
}
|
||||
// - Can only be used when `by_hour`, `by_minute` and `by_second` are used.
|
||||
// TODO don't know why this is true, but seems to loop forever otherwise.
|
||||
if rrule.by_easter.is_some()
|
||||
&& (rrule.by_hour.is_empty() || rrule.by_minute.is_empty() || rrule.by_second.is_empty())
|
||||
{
|
||||
return Err(ValidationError::InvalidByRuleWithByEaster);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_range_for_vec_error<'a, T: PartialOrd>(
|
||||
range: &RangeInclusive<T>,
|
||||
list: &'a [T],
|
||||
) -> Result<(), &'a T> {
|
||||
for item in list {
|
||||
// If value not in range = error
|
||||
if !range.contains(item) {
|
||||
return Err(item);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_range_for_vec<T: PartialOrd + ToString>(
|
||||
range: &RangeInclusive<T>,
|
||||
list: &[T],
|
||||
field: &str,
|
||||
) -> Result<(), ValidationError> {
|
||||
for item in list {
|
||||
// If value not in range = error
|
||||
if !range.contains(item) {
|
||||
return Err(ValidationError::InvalidFieldValueRange {
|
||||
field: field.to_string(),
|
||||
value: item.to_string(),
|
||||
start_idx: range.start().to_string(),
|
||||
end_idx: range.end().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_not_equal_for_vec<T: PartialEq<T> + ToString>(
|
||||
value: &T,
|
||||
list: &[T],
|
||||
field: &str,
|
||||
) -> Result<(), ValidationError> {
|
||||
if list.iter().any(|item| item == value) {
|
||||
return Err(ValidationError::InvalidFieldValue {
|
||||
field: field.to_string(),
|
||||
value: value.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::icalendar::ICalendarFrequency;
|
||||
use chrono::TimeZone;
|
||||
|
||||
const UTC: Tz = Tz::UTC;
|
||||
|
||||
#[test]
|
||||
fn rejects_by_set_pos_without_byxxx_rule() {
|
||||
let rrule = RRule {
|
||||
by_set_pos: vec![-1],
|
||||
..Default::default()
|
||||
};
|
||||
let dt_start = UTC.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap();
|
||||
let res = validate_rrule_forced(&rrule, &dt_start);
|
||||
assert!(res.is_err());
|
||||
let err = res.unwrap_err();
|
||||
assert_eq!(err, ValidationError::BySetPosWithoutByRule);
|
||||
|
||||
// When creating RRule directly,
|
||||
// if `rrule.by_set_hour` is empty, then it is going to default to
|
||||
// `dt_start.hour()`, therefore, there is always a BYXXX
|
||||
// rule, and the rrule is accepted.
|
||||
let res = rrule.validate(dt_start);
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_by_rule_field_with_invalid_value() {
|
||||
let tests = [
|
||||
(
|
||||
"BYSETPOS",
|
||||
RRule {
|
||||
by_set_pos: vec![0],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"BYSETPOS",
|
||||
RRule {
|
||||
by_set_pos: vec![1, -2, 0],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"BYMONTHDAY",
|
||||
RRule {
|
||||
by_month_day: vec![0],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"BYYEARDAY",
|
||||
RRule {
|
||||
by_year_day: vec![0],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
];
|
||||
for (field, rrule) in tests {
|
||||
let res =
|
||||
validate_rrule_forced(&rrule, &UTC.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap());
|
||||
assert!(res.is_err());
|
||||
let err = res.unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
ValidationError::InvalidFieldValue {
|
||||
field: field.into(),
|
||||
value: "0".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_by_rule_field_with_value_outside_allowed_range() {
|
||||
let tests = [
|
||||
(
|
||||
"BYMONTHDAY",
|
||||
RRule {
|
||||
by_month_day: vec![34],
|
||||
..Default::default()
|
||||
},
|
||||
"34",
|
||||
"-31",
|
||||
"31",
|
||||
),
|
||||
(
|
||||
"BYYEARDAY",
|
||||
RRule {
|
||||
by_year_day: vec![17, 400],
|
||||
..Default::default()
|
||||
},
|
||||
"400",
|
||||
"-366",
|
||||
"366",
|
||||
),
|
||||
];
|
||||
for (field, rrule, value, start_idx, end_idx) in tests {
|
||||
let res =
|
||||
validate_rrule_forced(&rrule, &UTC.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap());
|
||||
assert!(res.is_err());
|
||||
let err = res.unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
ValidationError::InvalidFieldValueRange {
|
||||
field: field.into(),
|
||||
value: value.into(),
|
||||
start_idx: start_idx.into(),
|
||||
end_idx: end_idx.into()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_by_rule_value_outside_allowed_freq_range() {
|
||||
let tests = [
|
||||
(
|
||||
"BYSETPOS",
|
||||
RRule {
|
||||
freq: ICalendarFrequency::Hourly,
|
||||
by_set_pos: vec![30],
|
||||
..Default::default()
|
||||
},
|
||||
"30",
|
||||
"-24",
|
||||
"24",
|
||||
),
|
||||
(
|
||||
"BYSETPOS",
|
||||
RRule {
|
||||
freq: ICalendarFrequency::Yearly,
|
||||
by_set_pos: vec![400],
|
||||
..Default::default()
|
||||
},
|
||||
"400",
|
||||
"-366",
|
||||
"366",
|
||||
),
|
||||
];
|
||||
for (field, rrule, value, start_idx, end_idx) in tests {
|
||||
let res =
|
||||
validate_rrule_forced(&rrule, &UTC.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap());
|
||||
assert!(res.is_err());
|
||||
let err = res.unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
ValidationError::InvalidFieldValueRangeWithFreq {
|
||||
field: field.into(),
|
||||
freq: rrule.freq,
|
||||
value: value.into(),
|
||||
start_idx: start_idx.into(),
|
||||
end_idx: end_idx.into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_by_rule_and_freq_combos() {
|
||||
let tests = [
|
||||
(
|
||||
"BYMONTHDAY",
|
||||
RRule {
|
||||
freq: ICalendarFrequency::Weekly,
|
||||
by_month_day: vec![-1],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"BYYEARDAY",
|
||||
RRule {
|
||||
freq: ICalendarFrequency::Monthly,
|
||||
by_year_day: vec![120],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"BYYEARDAY",
|
||||
RRule {
|
||||
freq: ICalendarFrequency::Weekly,
|
||||
by_year_day: vec![120],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"BYYEARDAY",
|
||||
RRule {
|
||||
freq: ICalendarFrequency::Daily,
|
||||
by_year_day: vec![120],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
];
|
||||
for (field, rrule) in tests {
|
||||
let res =
|
||||
validate_rrule_forced(&rrule, &UTC.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap());
|
||||
assert!(res.is_err());
|
||||
let err = res.unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
ValidationError::InvalidByRuleAndFrequency {
|
||||
by_rule: field.into(),
|
||||
freq: rrule.freq
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_start_date_after_until() {
|
||||
let rrule = RRule {
|
||||
until: Some(UTC.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()),
|
||||
..Default::default()
|
||||
};
|
||||
let dt_start = UTC.with_ymd_and_hms(2020, 1, 2, 0, 0, 0).unwrap();
|
||||
let res = validate_rrule_forced(&rrule, &dt_start);
|
||||
assert!(res.is_err());
|
||||
let err = res.unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
ValidationError::UntilBeforeStart {
|
||||
until: rrule.until.unwrap().to_rfc3339(),
|
||||
dt_start: dt_start.to_rfc3339()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_until_with_compatible_timezone() {
|
||||
fn t(start_tz: Tz, until_tz: Tz) -> (chrono::DateTime<Tz>, chrono::DateTime<Tz>) {
|
||||
(
|
||||
start_tz.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(),
|
||||
until_tz.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
let tests = [
|
||||
t(Tz::Floating, Tz::Floating),
|
||||
t(Tz::Floating, UTC),
|
||||
t(UTC, UTC),
|
||||
];
|
||||
|
||||
for (start_date, until) in tests {
|
||||
let rrule = RRule {
|
||||
until: Some(until),
|
||||
..Default::default()
|
||||
};
|
||||
let res = validate_rrule_forced(&rrule, &start_date);
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_until_with_incompatible_timezone() {
|
||||
fn t(start_tz: Tz, until_tz: Tz) -> (chrono::DateTime<Tz>, chrono::DateTime<Tz>) {
|
||||
(
|
||||
start_tz.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(),
|
||||
until_tz.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
let tests = [
|
||||
t(Tz::Tz(chrono_tz::Tz::UTC), Tz::Floating),
|
||||
t(Tz::Tz(chrono_tz::Europe::Berlin), Tz::Floating),
|
||||
t(Tz::Floating, Tz::Tz(chrono_tz::Europe::Berlin)),
|
||||
];
|
||||
|
||||
for (start_date, until) in tests {
|
||||
let rrule = RRule {
|
||||
until: Some(until),
|
||||
..Default::default()
|
||||
};
|
||||
let res = validate_rrule_forced(&rrule, &start_date);
|
||||
assert!(res.is_err());
|
||||
let err = res.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ValidationError::DtStartUntilMismatchTimezone { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 Fredrik Meringdal, Ralph Bisschops <https://github.com/fmeringdal/rust-rrule>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::{
|
||||
masks::MASKS,
|
||||
rrule::RRule,
|
||||
utils::{days_since_unix_epoch, get_year_len, pymod},
|
||||
};
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct BaseMasks {
|
||||
month_mask: &'static [u8],
|
||||
month_day_mask: &'static [i8],
|
||||
neg_month_day_mask: &'static [i8],
|
||||
month_range: &'static [u16],
|
||||
weekday_mask: &'static [u32],
|
||||
}
|
||||
|
||||
fn base_year_masks(year_weekday: u16, year_len: u16) -> BaseMasks {
|
||||
if year_len == 365 {
|
||||
BaseMasks {
|
||||
month_mask: &MASKS.month_365,
|
||||
month_day_mask: &MASKS.month_day_365,
|
||||
neg_month_day_mask: &MASKS.neg_month_day_365,
|
||||
month_range: &MASKS.month_365_range,
|
||||
weekday_mask: &MASKS.weekday[usize::from(year_weekday)..],
|
||||
}
|
||||
} else {
|
||||
BaseMasks {
|
||||
month_mask: &MASKS.month_366,
|
||||
month_day_mask: &MASKS.month_day_366,
|
||||
neg_month_day_mask: &MASKS.neg_month_day_366,
|
||||
month_range: &MASKS.month_366_range,
|
||||
weekday_mask: &MASKS.weekday[usize::from(year_weekday)..],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct YearInfo {
|
||||
/// The year
|
||||
pub year: i32,
|
||||
/// Number of days in the current year (365 or 366)
|
||||
pub year_len: u16,
|
||||
/// Number of days in the next year (365 or 366)
|
||||
pub next_year_len: u16,
|
||||
/// Number of days since Unix epoch
|
||||
pub year_ordinal: i64,
|
||||
pub month_mask: &'static [u8],
|
||||
pub month_day_mask: &'static [i8],
|
||||
pub neg_month_day_mask: &'static [i8],
|
||||
pub month_range: &'static [u16],
|
||||
pub weekday_mask: &'static [u32],
|
||||
/// Week number mask
|
||||
pub week_no_mask: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl YearInfo {
|
||||
pub fn new(year: i32, rrule: &RRule) -> Self {
|
||||
// It should never fail, since there is always a 1st of January, is there?
|
||||
let first_year_day = Utc.with_ymd_and_hms(year, 1, 1, 0, 0, 0).unwrap();
|
||||
|
||||
let year_len = get_year_len(year);
|
||||
let next_year_len = get_year_len(year + 1);
|
||||
let year_ordinal = days_since_unix_epoch(&first_year_day);
|
||||
let year_start_weekday = first_year_day.weekday().num_days_from_monday() as u16;
|
||||
let base_masks = base_year_masks(year_start_weekday, year_len);
|
||||
|
||||
let mut result = Self {
|
||||
year,
|
||||
year_len,
|
||||
next_year_len,
|
||||
year_ordinal,
|
||||
week_no_mask: None,
|
||||
month_mask: base_masks.month_mask,
|
||||
month_day_mask: base_masks.month_day_mask,
|
||||
neg_month_day_mask: base_masks.neg_month_day_mask,
|
||||
month_range: base_masks.month_range,
|
||||
weekday_mask: base_masks.weekday_mask,
|
||||
};
|
||||
|
||||
if rrule.by_week_no.is_empty() {
|
||||
return result;
|
||||
}
|
||||
|
||||
let mut week_no_mask = vec![0; usize::from(year_len) + 7];
|
||||
|
||||
let rrule_week_start = rrule.week_start.num_days_from_monday() as u16;
|
||||
let mut no1_week_start = pymod(7 - year_start_weekday + rrule_week_start, 7);
|
||||
let first_week_start = no1_week_start;
|
||||
let year_len_ext = if no1_week_start >= 4 {
|
||||
no1_week_start = 0;
|
||||
// Number of days in the year, plus the days we got
|
||||
// from last year.
|
||||
let diff = i32::from(year_start_weekday) - i32::from(rrule_week_start);
|
||||
result.year_len + pymod(diff, 7) as u16
|
||||
} else {
|
||||
// Number of days in the year, minus the days we
|
||||
// left in last year.
|
||||
year_len - no1_week_start
|
||||
};
|
||||
|
||||
let div = year_len_ext / 7;
|
||||
let year_mod = pymod(year_len_ext, 7);
|
||||
let num_weeks = div + (year_mod / 4);
|
||||
|
||||
for &(mut n) in &rrule.by_week_no {
|
||||
let num_weeks = num_weeks as i8;
|
||||
if n < 0 {
|
||||
n += num_weeks + 1;
|
||||
}
|
||||
if !(n > 0 && n <= num_weeks) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let i = if n > 1 {
|
||||
let n = n as u16;
|
||||
let mut i = no1_week_start + ((n - 1) * 7);
|
||||
if no1_week_start != first_week_start {
|
||||
i -= 7 - first_week_start;
|
||||
}
|
||||
i
|
||||
} else {
|
||||
no1_week_start
|
||||
};
|
||||
|
||||
for j in i..i + 7 {
|
||||
let j = usize::from(j);
|
||||
week_no_mask[j] = 1;
|
||||
if result.weekday_mask[j + 1] == rrule.week_start.num_days_from_monday() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rrule.by_week_no.contains(&1) {
|
||||
// Check week number 1 of next years as well
|
||||
let mut i = no1_week_start + num_weeks * 7;
|
||||
if no1_week_start != first_week_start {
|
||||
i -= 7 - first_week_start;
|
||||
}
|
||||
if i < year_len {
|
||||
// If week starts in next year, we
|
||||
// don't care about it.
|
||||
for j in i..i + 7 {
|
||||
let j = usize::from(j);
|
||||
week_no_mask[j] = 1;
|
||||
if result.weekday_mask[j + 1] == rrule.week_start.num_days_from_monday() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if no1_week_start > 0 {
|
||||
// Check last week number of last year as
|
||||
// well. If no1_week_start is 0, either the year
|
||||
// started on week start, or week number 1
|
||||
// got days from last year, so there are no
|
||||
// days from last year's last week number in
|
||||
// this year.
|
||||
let l_num_weeks = if rrule.by_week_no.contains(&-1) {
|
||||
-1
|
||||
} else {
|
||||
let l_year_weekday = Utc
|
||||
.with_ymd_and_hms(year - 1, 1, 1, 0, 0, 0)
|
||||
// It should never fail, since there is always a 1st of January, is there?
|
||||
.unwrap()
|
||||
.weekday()
|
||||
.num_days_from_monday() as u16;
|
||||
|
||||
let rrule_week_start = rrule.week_start.num_days_from_monday() as u16;
|
||||
let ln_no1_week_start = pymod(7 - l_year_weekday + rrule_week_start, 7);
|
||||
|
||||
let l_year_len = get_year_len(year - 1);
|
||||
let week_start = if ln_no1_week_start >= 4 {
|
||||
l_year_len
|
||||
+ pymod(i32::from(l_year_weekday) - i32::from(rrule_week_start), 7) as u16
|
||||
} else {
|
||||
year_len - no1_week_start
|
||||
};
|
||||
|
||||
(52 + pymod(week_start, 7) / 4) as i8
|
||||
};
|
||||
|
||||
if rrule.by_week_no.contains(&l_num_weeks) {
|
||||
for i in 0..no1_week_start {
|
||||
let i = usize::from(i);
|
||||
week_no_mask[i] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.week_no_mask = Some(week_no_mask);
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
+808
@@ -0,0 +1,808 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
common::{
|
||||
IanaType, PartialDateTime,
|
||||
parser::{Boolean, Integer},
|
||||
},
|
||||
icalendar::*,
|
||||
};
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
|
||||
impl ICalendar {
|
||||
pub fn remove_component_ids(&mut self, component_ids: &[u32]) {
|
||||
// Validate component IDs
|
||||
let max_component_id = self.components.len() as u32;
|
||||
let mut remove_component_ids = AHashSet::from_iter(
|
||||
component_ids
|
||||
.iter()
|
||||
.filter(|id| **id < max_component_id)
|
||||
.cloned(),
|
||||
);
|
||||
|
||||
// Add sub-components to the set
|
||||
for (component_id, component) in self.components.iter().enumerate() {
|
||||
if remove_component_ids.contains(&(component_id as u32)) {
|
||||
remove_component_ids.extend(&component.component_ids);
|
||||
}
|
||||
}
|
||||
|
||||
if !remove_component_ids.is_empty() {
|
||||
let id_mappings = (0..max_component_id)
|
||||
.filter(|i| !remove_component_ids.contains(i))
|
||||
.enumerate()
|
||||
.map(|(new_id, old_id)| (old_id, new_id as u32))
|
||||
.collect::<AHashMap<_, _>>();
|
||||
|
||||
for (component_id, mut component) in
|
||||
std::mem::replace(&mut self.components, Vec::with_capacity(id_mappings.len()))
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if !remove_component_ids.contains(&(component_id as u32)) {
|
||||
let component_ids = component
|
||||
.component_ids
|
||||
.iter()
|
||||
.filter_map(|id| id_mappings.get(id).cloned())
|
||||
.collect();
|
||||
component.component_ids = component_ids;
|
||||
self.components.push(component);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copy_timezones(&mut self, other: &ICalendar) {
|
||||
for component in &other.components {
|
||||
if component.component_type == ICalendarComponentType::VTimezone {
|
||||
let tz_component_id = self.components.len();
|
||||
self.components[0]
|
||||
.component_ids
|
||||
.insert(1, tz_component_id as u32);
|
||||
self.components.push(ICalendarComponent {
|
||||
component_type: ICalendarComponentType::VTimezone,
|
||||
entries: component.entries.clone(),
|
||||
component_ids: vec![],
|
||||
});
|
||||
for component_id in &component.component_ids {
|
||||
let item_id = self.components.len() as u32;
|
||||
let item = &other.components[*component_id as usize];
|
||||
self.components.push(ICalendarComponent {
|
||||
component_type: item.component_type.clone(),
|
||||
entries: item.entries.clone(),
|
||||
component_ids: vec![],
|
||||
});
|
||||
self.components[tz_component_id].component_ids.push(item_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ICalendarComponent {
|
||||
pub fn new(component_type: ICalendarComponentType) -> Self {
|
||||
Self {
|
||||
component_type,
|
||||
entries: Vec::new(),
|
||||
component_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_dtstamp(&mut self, dt_stamp: PartialDateTime) {
|
||||
self.entries.push(ICalendarEntry {
|
||||
name: ICalendarProperty::Dtstamp,
|
||||
params: vec![],
|
||||
values: vec![ICalendarValue::PartialDateTime(Box::new(dt_stamp))],
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_sequence(&mut self, sequence: i64) {
|
||||
self.entries.push(ICalendarEntry {
|
||||
name: ICalendarProperty::Sequence,
|
||||
params: vec![],
|
||||
values: vec![ICalendarValue::Integer(sequence)],
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_uid(&mut self, uid: &str) {
|
||||
self.entries.push(ICalendarEntry {
|
||||
name: ICalendarProperty::Uid,
|
||||
params: vec![],
|
||||
values: vec![ICalendarValue::Text(uid.to_string())],
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_property(&mut self, name: ICalendarProperty, value: impl Into<ICalendarValue>) {
|
||||
self.entries.push(ICalendarEntry {
|
||||
name,
|
||||
params: vec![],
|
||||
values: vec![value.into()],
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_property_with_params(
|
||||
&mut self,
|
||||
name: ICalendarProperty,
|
||||
params: impl IntoIterator<Item = ICalendarParameter>,
|
||||
value: impl Into<ICalendarValue>,
|
||||
) {
|
||||
self.entries.push(ICalendarEntry {
|
||||
name,
|
||||
params: params.into_iter().collect(),
|
||||
values: vec![value.into()],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ICalendarEntry {
|
||||
pub fn new(name: ICalendarProperty) -> Self {
|
||||
Self {
|
||||
name,
|
||||
params: vec![],
|
||||
values: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_params(mut self, params: Vec<ICalendarParameter>) -> Self {
|
||||
self.params = params;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_value(mut self, value: impl Into<ICalendarValue>) -> Self {
|
||||
self.values.push(value.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_values(mut self, values: Vec<ICalendarValue>) -> Self {
|
||||
self.values = values;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_param(mut self, param: impl Into<ICalendarParameter>) -> Self {
|
||||
self.params.push(param.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_param(&mut self, param: impl Into<ICalendarParameter>) {
|
||||
self.params.push(param.into());
|
||||
}
|
||||
|
||||
pub fn with_param_opt(mut self, param: Option<impl Into<ICalendarParameter>>) -> Self {
|
||||
if let Some(param) = param {
|
||||
self.params.push(param.into());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_type(&self, typ: &ICalendarValueType) -> bool {
|
||||
self.parameters(&ICalendarParameterName::Value)
|
||||
.any(|p| matches!(p, ICalendarParameterValue::Value(v) if v == typ))
|
||||
}
|
||||
}
|
||||
|
||||
impl ICalendarParameter {
|
||||
pub fn new(name: ICalendarParameterName, value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn altrep(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Altrep,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cn(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Cn,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cutype(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Cutype,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delegated_from(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::DelegatedFrom,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delegated_to(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::DelegatedTo,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dir(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Dir,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fmttype(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Fmttype,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fbtype(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Fbtype,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn language(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Language,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn member(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Member,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn partstat(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Partstat,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn range(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Range,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn related(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Related,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reltype(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Reltype,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn role(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Role,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rsvp(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Rsvp,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schedule_agent(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::ScheduleAgent,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schedule_force_send(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::ScheduleForceSend,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schedule_status(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::ScheduleStatus,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sent_by(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::SentBy,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tzid(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Tzid,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Value,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Display,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn email(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Email,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn feature(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Feature,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Label,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Size,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filename(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Filename,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn managed_id(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::ManagedId,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn order(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Order,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schema(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Schema,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn derived(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Derived,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gap(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Gap,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn linkrel(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Linkrel,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jsptr(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Jsptr,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jsid(value: impl Into<ICalendarParameterValue>) -> Self {
|
||||
ICalendarParameter {
|
||||
name: ICalendarParameterName::Jsid,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ICalendarParameterValue {
|
||||
fn from(text: String) -> Self {
|
||||
ICalendarParameterValue::Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u64> for ICalendarParameterValue {
|
||||
fn from(integer: u64) -> Self {
|
||||
ICalendarParameterValue::Integer(integer)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Integer> for ICalendarParameterValue {
|
||||
fn from(integer: Integer) -> Self {
|
||||
ICalendarParameterValue::Integer(integer.0.unsigned_abs())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Boolean> for ICalendarParameterValue {
|
||||
fn from(boolean: Boolean) -> Self {
|
||||
ICalendarParameterValue::Bool(boolean.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> for ICalendarParameterValue {
|
||||
fn from(boolean: bool) -> Self {
|
||||
ICalendarParameterValue::Bool(boolean)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Uri> for ICalendarParameterValue {
|
||||
fn from(uri: Uri) -> Self {
|
||||
ICalendarParameterValue::Uri(uri)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarUserTypes> for ICalendarParameterValue {
|
||||
fn from(cutype: ICalendarUserTypes) -> Self {
|
||||
ICalendarParameterValue::Cutype(cutype)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarFreeBusyType> for ICalendarParameterValue {
|
||||
fn from(fbtype: ICalendarFreeBusyType) -> Self {
|
||||
ICalendarParameterValue::Fbtype(fbtype)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarParticipationStatus> for ICalendarParameterValue {
|
||||
fn from(partstat: ICalendarParticipationStatus) -> Self {
|
||||
ICalendarParameterValue::Partstat(partstat)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarRelated> for ICalendarParameterValue {
|
||||
fn from(related: ICalendarRelated) -> Self {
|
||||
ICalendarParameterValue::Related(related)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarRelationshipType> for ICalendarParameterValue {
|
||||
fn from(reltype: ICalendarRelationshipType) -> Self {
|
||||
ICalendarParameterValue::Reltype(reltype)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarParticipationRole> for ICalendarParameterValue {
|
||||
fn from(role: ICalendarParticipationRole) -> Self {
|
||||
ICalendarParameterValue::Role(role)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarScheduleAgentValue> for ICalendarParameterValue {
|
||||
fn from(schedule_agent: ICalendarScheduleAgentValue) -> Self {
|
||||
ICalendarParameterValue::ScheduleAgent(schedule_agent)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarScheduleForceSendValue> for ICalendarParameterValue {
|
||||
fn from(schedule_force_send: ICalendarScheduleForceSendValue) -> Self {
|
||||
ICalendarParameterValue::ScheduleForceSend(schedule_force_send)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarValueType> for ICalendarParameterValue {
|
||||
fn from(value: ICalendarValueType) -> Self {
|
||||
ICalendarParameterValue::Value(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarDisplayType> for ICalendarParameterValue {
|
||||
fn from(display: ICalendarDisplayType) -> Self {
|
||||
ICalendarParameterValue::Display(display)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarFeatureType> for ICalendarParameterValue {
|
||||
fn from(feature: ICalendarFeatureType) -> Self {
|
||||
ICalendarParameterValue::Feature(feature)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarDuration> for ICalendarParameterValue {
|
||||
fn from(duration: ICalendarDuration) -> Self {
|
||||
ICalendarParameterValue::Duration(duration)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LinkRelation> for ICalendarParameterValue {
|
||||
fn from(linkrel: LinkRelation) -> Self {
|
||||
ICalendarParameterValue::Linkrel(linkrel)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Into<ICalendarParameterValue>> From<IanaType<T, String>> for ICalendarParameterValue {
|
||||
fn from(value: IanaType<T, String>) -> Self {
|
||||
match value {
|
||||
IanaType::Iana(v) => v.into(),
|
||||
IanaType::Other(s) => s.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<u8>> for ICalendarValue {
|
||||
fn from(value: Vec<u8>) -> Self {
|
||||
ICalendarValue::Binary(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> for ICalendarValue {
|
||||
fn from(value: bool) -> Self {
|
||||
ICalendarValue::Boolean(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Uri> for ICalendarValue {
|
||||
fn from(value: Uri) -> Self {
|
||||
ICalendarValue::Uri(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PartialDateTime> for ICalendarValue {
|
||||
fn from(value: PartialDateTime) -> Self {
|
||||
ICalendarValue::PartialDateTime(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Box<PartialDateTime>> for ICalendarValue {
|
||||
fn from(value: Box<PartialDateTime>) -> Self {
|
||||
ICalendarValue::PartialDateTime(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarDuration> for ICalendarValue {
|
||||
fn from(value: ICalendarDuration) -> Self {
|
||||
ICalendarValue::Duration(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarRecurrenceRule> for ICalendarValue {
|
||||
fn from(value: ICalendarRecurrenceRule) -> Self {
|
||||
ICalendarValue::RecurrenceRule(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Box<ICalendarRecurrenceRule>> for ICalendarValue {
|
||||
fn from(value: Box<ICalendarRecurrenceRule>) -> Self {
|
||||
ICalendarValue::RecurrenceRule(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarPeriod> for ICalendarValue {
|
||||
fn from(value: ICalendarPeriod) -> Self {
|
||||
ICalendarValue::Period(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for ICalendarValue {
|
||||
fn from(value: f64) -> Self {
|
||||
ICalendarValue::Float(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for ICalendarValue {
|
||||
fn from(value: i64) -> Self {
|
||||
ICalendarValue::Integer(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ICalendarValue {
|
||||
fn from(value: String) -> Self {
|
||||
ICalendarValue::Text(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ICalendarValue {
|
||||
fn from(value: &str) -> Self {
|
||||
ICalendarValue::Text(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CalendarScale> for ICalendarValue {
|
||||
fn from(value: CalendarScale) -> Self {
|
||||
ICalendarValue::CalendarScale(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarMethod> for ICalendarValue {
|
||||
fn from(value: ICalendarMethod) -> Self {
|
||||
ICalendarValue::Method(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarClassification> for ICalendarValue {
|
||||
fn from(value: ICalendarClassification) -> Self {
|
||||
ICalendarValue::Classification(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarStatus> for ICalendarValue {
|
||||
fn from(value: ICalendarStatus) -> Self {
|
||||
ICalendarValue::Status(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarTransparency> for ICalendarValue {
|
||||
fn from(value: ICalendarTransparency) -> Self {
|
||||
ICalendarValue::Transparency(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarAction> for ICalendarValue {
|
||||
fn from(value: ICalendarAction) -> Self {
|
||||
ICalendarValue::Action(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarFreeBusyType> for ICalendarValue {
|
||||
fn from(value: ICalendarFreeBusyType) -> Self {
|
||||
ICalendarValue::BusyType(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarParticipantType> for ICalendarValue {
|
||||
fn from(value: ICalendarParticipantType) -> Self {
|
||||
ICalendarValue::ParticipantType(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarResourceType> for ICalendarValue {
|
||||
fn from(value: ICalendarResourceType) -> Self {
|
||||
ICalendarValue::ResourceType(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ICalendarProximityValue> for ICalendarValue {
|
||||
fn from(value: ICalendarProximityValue) -> Self {
|
||||
ICalendarValue::Proximity(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::icalendar::ICalendar;
|
||||
|
||||
#[test]
|
||||
fn remove_component_ids() {
|
||||
let mut ical =
|
||||
ICalendar::parse(std::fs::read_to_string("resources/ical/007.ics").unwrap()).unwrap();
|
||||
ical.remove_component_ids(&[2, 5, 7]);
|
||||
|
||||
let max_component_id = ical.components.len() as u32;
|
||||
for component in &ical.components {
|
||||
for id in &component.component_ids {
|
||||
assert!(*id < max_component_id);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
ical.to_string().replace("\r\n", "\n"),
|
||||
r#"BEGIN:VCALENDAR
|
||||
PRODID:-//Google Inc//Google Calendar 70.9054//EN
|
||||
VERSION:2.0
|
||||
CALSCALE:GREGORIAN
|
||||
METHOD:PUBLISH
|
||||
X-WR-CALNAME:ANONYMOUS
|
||||
X-WR-TIMEZONE:America/Denver
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:America/Denver
|
||||
X-LIC-LOCATION:America/Denver
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETFROM:-0600
|
||||
TZOFFSETTO:-0700
|
||||
TZNAME:MST
|
||||
DTSTART:19701101T020000
|
||||
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=America/Denver:20200903T094000
|
||||
DTEND;TZID=America/Denver:20200903T095000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE
|
||||
EXDATE;TZID=America/Denver:20201015T094000
|
||||
DTSTAMP:20201124T214551Z
|
||||
UID:078tk5i2t4all3kk0jcoi3mdmd@google.com
|
||||
CREATED:20200903T032927Z
|
||||
DESCRIPTION:
|
||||
LAST-MODIFIED:20200903T032927Z
|
||||
LOCATION:
|
||||
SEQUENCE:0
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:Brain Break
|
||||
TRANSP:OPAQUE
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=America/Denver:20200903T095000
|
||||
DTEND;TZID=America/Denver:20200903T104000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE
|
||||
EXDATE;TZID=America/Denver:20201015T095000
|
||||
DTSTAMP:20201124T214551Z
|
||||
UID:11le1ep09hvog7dbotn6foj38e@google.com
|
||||
CREATED:20200903T032956Z
|
||||
DESCRIPTION:
|
||||
LAST-MODIFIED:20200903T032957Z
|
||||
LOCATION:
|
||||
SEQUENCE:0
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:Academic Time
|
||||
TRANSP:OPAQUE
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=Europe/Budapest:20201102T072000
|
||||
DTEND;TZID=Europe/Budapest:20201102T072000
|
||||
EXDATE;TZID=Europe/Budapest:20201221T072000
|
||||
EXDATE;TZID=Europe/Budapest:20201222T072000
|
||||
EXDATE;TZID=Europe/Budapest:20201223T072000
|
||||
EXDATE;TZID=Europe/Budapest:20201224T072000
|
||||
EXDATE;TZID=Europe/Budapest:20201225T072000
|
||||
EXDATE;TZID=Europe/Budapest:20201228T072000
|
||||
EXDATE;TZID=Europe/Budapest:20201229T072000
|
||||
EXDATE;TZID=Europe/Budapest:20201230T072000
|
||||
EXDATE;TZID=Europe/Budapest:20201231T072000
|
||||
EXDATE;TZID=Europe/Budapest:20210101T072000
|
||||
RRULE;X-BUSYMAC-REGENERATE=TRASH:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
|
||||
DTSTAMP:20201230T095550Z
|
||||
UID:*Masked away*
|
||||
CREATED:20201102T054749Z
|
||||
DESCRIPTION:
|
||||
LAST-MODIFIED:20201221T100211Z
|
||||
LOCATION:
|
||||
SEQUENCE:1
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:Invalid RRULE property
|
||||
TRANSP:OPAQUE
|
||||
X-BUSYMAC-LASTMODBY:*Masked away*
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"#
|
||||
)
|
||||
}
|
||||
}
|
||||
+773
@@ -0,0 +1,773 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::{
|
||||
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarPeriod, ICalendarProperty,
|
||||
ICalendarValue, timezone::TzResolver,
|
||||
};
|
||||
use crate::{
|
||||
common::{DateTimeResult, timezone::Tz},
|
||||
datecalc::{MAX_EXPANSION_WORK, RRuleIter, error::RRuleError, rrule::RRule},
|
||||
icalendar::ICalendarParameterName,
|
||||
};
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use chrono::{DateTime, TimeDelta, TimeZone, Timelike};
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(any(test, feature = "serde"), derive(serde::Serialize))]
|
||||
pub struct CalendarExpand {
|
||||
pub events: Vec<CalendarEvent<DateTime<Tz>, TimeOrDelta<DateTime<Tz>, TimeDelta>>>,
|
||||
pub errors: Vec<CalendarError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(any(test, feature = "serde"), derive(serde::Serialize))]
|
||||
pub struct CalendarEvent<S, E> {
|
||||
pub comp_id: u32,
|
||||
pub start: S,
|
||||
pub end: E,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
pub enum TimeOrDelta<T, D> {
|
||||
Time(T),
|
||||
Delta(D),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(any(test, feature = "serde"), derive(serde::Serialize))]
|
||||
pub struct CalendarError {
|
||||
pub comp_id: u32,
|
||||
pub error: CalendarErrorType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(any(test, feature = "serde"), derive(serde::Serialize))]
|
||||
pub enum CalendarErrorType {
|
||||
MissingDtStart,
|
||||
InvalidDtStart,
|
||||
InvalidDtEnd,
|
||||
InvalidDuration,
|
||||
RRule(RRuleError),
|
||||
// inbuxa: expansion stopped at this component's rule because all rules
|
||||
// together used up `MAX_EXPANSION_WORK`; later rules weren't expanded
|
||||
ExpansionBudgetExhausted,
|
||||
}
|
||||
|
||||
impl ICalendar {
|
||||
pub fn expand_dates(&self, default_tz: impl Into<Tz>, mut limit: usize) -> CalendarExpand {
|
||||
let tz_resolver = self.build_tz_resolver().with_default(default_tz);
|
||||
let mut expand = CalendarExpand::default();
|
||||
let mut rrules = Vec::new();
|
||||
let mut overridden = AHashMap::new();
|
||||
|
||||
for (comp_id, comp) in self.components.iter().enumerate() {
|
||||
if comp.component_type.has_time_ranges() {
|
||||
match comp.build_calendar_date(comp_id as u32, &tz_resolver, &mut expand.events) {
|
||||
Ok(Some(event)) => {
|
||||
if event.rrule.is_some() {
|
||||
rrules.push((comp_id as u32, event));
|
||||
} else if let Some(rid) = event.rid {
|
||||
overridden.insert((event.rrule_seq, rid), (comp_id as u32, event));
|
||||
} else if let Some(cal_event) = event.event {
|
||||
expand.events.push(cal_event);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
expand.errors.push(CalendarError {
|
||||
comp_id: comp_id as u32,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expand recurrences
|
||||
let mut budget = MAX_EXPANSION_WORK;
|
||||
for (mut comp_id, event) in rrules {
|
||||
let rrule = event.rrule.unwrap();
|
||||
let floating_start = if let Some(floating_start) = Tz::Floating
|
||||
.from_local_datetime(&event.dt_start.date_time)
|
||||
.single()
|
||||
{
|
||||
floating_start
|
||||
} else {
|
||||
expand.errors.push(CalendarError {
|
||||
comp_id,
|
||||
error: CalendarErrorType::InvalidDtStart,
|
||||
});
|
||||
continue;
|
||||
};
|
||||
let rrule = match rrule.validate(floating_start) {
|
||||
Ok(rrule) => rrule,
|
||||
Err(err) => {
|
||||
expand.errors.push(CalendarError {
|
||||
comp_id,
|
||||
error: CalendarErrorType::RRule(err),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let exdates = event
|
||||
.exdates
|
||||
.into_iter()
|
||||
.filter_map(|(tz_id, dt)| {
|
||||
dt.to_date_time_with_tz(
|
||||
tz_resolver.resolve_or_default(tz_id.or(event.dt_start_tzid)),
|
||||
)
|
||||
})
|
||||
.collect::<AHashSet<_>>();
|
||||
let mut override_offset = None;
|
||||
|
||||
// inbuxa: no instance can be added once the limit is reached
|
||||
if limit == 0 {
|
||||
continue;
|
||||
}
|
||||
let rule_comp_id = comp_id;
|
||||
let mut dates = RRuleIter::new(&rrule, &floating_start, true).with_budget(budget);
|
||||
|
||||
for date in dates.by_ref() {
|
||||
if limit != 0 {
|
||||
limit -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
let mut date = if date.timezone().is_floating() {
|
||||
event
|
||||
.start_tz
|
||||
.from_local_datetime(&date.naive_local())
|
||||
.single()
|
||||
.unwrap_or(date)
|
||||
} else {
|
||||
date
|
||||
};
|
||||
if let Some(override_offset) = override_offset {
|
||||
date += override_offset;
|
||||
}
|
||||
match overridden.remove(&(event.rrule_seq, date)) {
|
||||
Some((new_comp_id, overridden_event)) => {
|
||||
if let Some(new_event) = overridden_event.event {
|
||||
if overridden_event.rid_this_and_future {
|
||||
comp_id = new_comp_id;
|
||||
override_offset = Some(new_event.start - date);
|
||||
}
|
||||
expand.events.push(new_event);
|
||||
}
|
||||
}
|
||||
None if !exdates.contains(&date) => {
|
||||
expand.events.push(CalendarEvent {
|
||||
start: date,
|
||||
end: TimeOrDelta::Delta(event.default_duration),
|
||||
comp_id,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
budget = dates.budget;
|
||||
if dates.budget_exhausted {
|
||||
expand.errors.push(CalendarError {
|
||||
comp_id: rule_comp_id,
|
||||
error: CalendarErrorType::ExpansionBudgetExhausted,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add missing overridden events (this should not occur unless the iCalendar is malformed)
|
||||
for (_, event) in overridden.into_values() {
|
||||
if let Some(cal_event) = event.event {
|
||||
expand.events.push(cal_event);
|
||||
}
|
||||
}
|
||||
|
||||
expand
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
struct CalendarEventBuilder<'x> {
|
||||
event: Option<CalendarEvent<DateTime<Tz>, TimeOrDelta<DateTime<Tz>, TimeDelta>>>,
|
||||
dt_start: DateTimeResult,
|
||||
dt_start_tzid: Option<&'x str>,
|
||||
start_tz: Tz,
|
||||
default_duration: TimeDelta,
|
||||
rrule: Option<RRule>,
|
||||
rrule_seq: i64,
|
||||
exdates: Vec<(Option<&'x str>, DateTimeResult)>,
|
||||
rid: Option<DateTime<Tz>>,
|
||||
rid_this_and_future: bool,
|
||||
}
|
||||
|
||||
impl ICalendarComponent {
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn build_calendar_date(
|
||||
&self,
|
||||
comp_id: u32,
|
||||
tz_resolver: &TzResolver<&'_ str>,
|
||||
events: &mut Vec<CalendarEvent<DateTime<Tz>, TimeOrDelta<DateTime<Tz>, TimeDelta>>>,
|
||||
) -> Result<Option<CalendarEventBuilder<'_>>, CalendarErrorType> {
|
||||
let mut dt_start = None;
|
||||
let mut dt_start_tzid = None;
|
||||
let mut dt_start_has_time = false;
|
||||
let mut dt_end: Option<DateTimeResult> = None;
|
||||
let mut dt_end_tzid = None;
|
||||
let mut todo_dates = vec![];
|
||||
let mut rid: Option<DateTimeResult> = None;
|
||||
let mut rid_tzid = None;
|
||||
let mut rid_this_and_future = false;
|
||||
let mut duration = None;
|
||||
let mut rrule = None;
|
||||
let mut rrule_seq = i64::MAX;
|
||||
let mut rdates = vec![];
|
||||
let mut rdates_periods = vec![];
|
||||
let mut exdates = vec![];
|
||||
|
||||
for entry in &self.entries {
|
||||
match (&entry.name, entry.values.first()) {
|
||||
(ICalendarProperty::Dtstart, Some(ICalendarValue::PartialDateTime(dt))) => {
|
||||
dt_start = dt.to_date_time();
|
||||
dt_start_tzid = entry.tz_id();
|
||||
dt_start_has_time = dt.has_time();
|
||||
}
|
||||
(ICalendarProperty::Dtend, Some(ICalendarValue::PartialDateTime(dt))) => {
|
||||
if let Some(dt) = dt.to_date_time() {
|
||||
dt_end = Some(dt);
|
||||
dt_end_tzid = entry.tz_id();
|
||||
}
|
||||
}
|
||||
(
|
||||
ICalendarProperty::Due
|
||||
| ICalendarProperty::Completed
|
||||
| ICalendarProperty::Created,
|
||||
Some(ICalendarValue::PartialDateTime(dt)),
|
||||
) if self.component_type == ICalendarComponentType::VTodo => {
|
||||
todo_dates.push((&entry.name, dt.to_date_time(), entry.tz_id()));
|
||||
}
|
||||
(ICalendarProperty::RecurrenceId, Some(ICalendarValue::PartialDateTime(dt))) => {
|
||||
if let Some(dt) = dt.to_date_time() {
|
||||
for param in &entry.params {
|
||||
match ¶m.name {
|
||||
ICalendarParameterName::Tzid => {
|
||||
rid_tzid = param.value.as_text();
|
||||
}
|
||||
ICalendarParameterName::Range => {
|
||||
rid_this_and_future = true;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
rid = Some(dt);
|
||||
}
|
||||
}
|
||||
(ICalendarProperty::Duration, Some(ICalendarValue::Duration(dur))) => {
|
||||
duration = Some(dur);
|
||||
}
|
||||
(ICalendarProperty::Rrule, Some(ICalendarValue::RecurrenceRule(rule))) => {
|
||||
rrule = RRule::from_floating_ical(rule);
|
||||
}
|
||||
(ICalendarProperty::Sequence, Some(ICalendarValue::Integer(seq))) => {
|
||||
rrule_seq = *seq;
|
||||
}
|
||||
(ICalendarProperty::Rdate, _) => {
|
||||
let tz_id = entry.tz_id();
|
||||
for value in &entry.values {
|
||||
match value {
|
||||
ICalendarValue::PartialDateTime(dt) => {
|
||||
if let Some(dt) = dt.to_date_time() {
|
||||
rdates.push((tz_id, dt));
|
||||
}
|
||||
}
|
||||
ICalendarValue::Period(period) => match period {
|
||||
ICalendarPeriod::Range { start, end } => {
|
||||
if let (Some(start), Some(end)) =
|
||||
(start.to_date_time(), end.to_date_time())
|
||||
{
|
||||
rdates_periods.push((tz_id, start, TimeOrDelta::Time(end)));
|
||||
}
|
||||
}
|
||||
ICalendarPeriod::Duration { start, duration } => {
|
||||
if let (Some(start), Some(duration)) =
|
||||
(start.to_date_time(), duration.to_time_delta())
|
||||
{
|
||||
rdates_periods.push((
|
||||
tz_id,
|
||||
start,
|
||||
TimeOrDelta::Delta(duration),
|
||||
));
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
(ICalendarProperty::Exdate, _) => {
|
||||
let tz_id = entry.tz_id();
|
||||
for value in &entry.values {
|
||||
if let ICalendarValue::PartialDateTime(dt) = value
|
||||
&& let Some(dt) = dt.to_date_time()
|
||||
{
|
||||
exdates.push((tz_id, dt));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
let dt_start = match dt_start {
|
||||
Some(dt_start) => dt_start,
|
||||
None => match &rid {
|
||||
Some(rid) => {
|
||||
dt_start_tzid = rid_tzid;
|
||||
rid.clone()
|
||||
}
|
||||
None => match self.component_type {
|
||||
ICalendarComponentType::VEvent => {
|
||||
return Err(CalendarErrorType::MissingDtStart);
|
||||
}
|
||||
ICalendarComponentType::VTodo => {
|
||||
let mut due_idx = None;
|
||||
let mut completed_idx = None;
|
||||
let mut created_idx = None;
|
||||
|
||||
for (idx, (prop, dt, _)) in todo_dates.iter().enumerate() {
|
||||
if dt.is_some() {
|
||||
match prop {
|
||||
ICalendarProperty::Due => due_idx = Some(idx),
|
||||
ICalendarProperty::Completed => completed_idx = Some(idx),
|
||||
ICalendarProperty::Created => created_idx = Some(idx),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (due_idx, completed_idx, created_idx) {
|
||||
(Some(due_idx), _, _) => {
|
||||
let due = &mut todo_dates[due_idx];
|
||||
dt_start_tzid = due.2;
|
||||
dt_start_has_time = true;
|
||||
due.1.take().unwrap()
|
||||
}
|
||||
(_, Some(completed_idx), Some(created_idx)) => {
|
||||
let completed = &mut todo_dates[completed_idx];
|
||||
dt_end = completed.1.take();
|
||||
dt_end_tzid = completed.2;
|
||||
|
||||
let created = &mut todo_dates[created_idx];
|
||||
dt_start_tzid = created.2;
|
||||
created.1.take().unwrap()
|
||||
}
|
||||
(_, Some(date_idx), _) | (_, _, Some(date_idx)) => {
|
||||
let date = &mut todo_dates[date_idx];
|
||||
dt_start_tzid = date.2;
|
||||
dt_start_has_time = true;
|
||||
date.1.take().unwrap()
|
||||
}
|
||||
_ => {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Ok(None);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
let mut event = None;
|
||||
let start_tz = tz_resolver.resolve_or_default(dt_start_tzid);
|
||||
let dt_start_tz = dt_start
|
||||
.to_date_time_with_tz(start_tz)
|
||||
.ok_or(CalendarErrorType::InvalidDtStart)?;
|
||||
let default_duration = if let Some(dt_end) = dt_end {
|
||||
let end = dt_end
|
||||
.to_date_time_with_tz(tz_resolver.resolve_or_default(dt_end_tzid.or(dt_start_tzid)))
|
||||
.ok_or(CalendarErrorType::InvalidDtEnd)?;
|
||||
|
||||
if rrule.is_none() {
|
||||
event = Some(CalendarEvent {
|
||||
start: dt_start_tz,
|
||||
end: TimeOrDelta::Time(end),
|
||||
comp_id,
|
||||
});
|
||||
}
|
||||
dt_end.date_time - dt_start.date_time
|
||||
} else if let Some(duration) = duration {
|
||||
let duration = duration
|
||||
.to_time_delta()
|
||||
.ok_or(CalendarErrorType::InvalidDuration)?;
|
||||
if rrule.is_none() {
|
||||
event = Some(CalendarEvent {
|
||||
start: dt_start_tz,
|
||||
end: TimeOrDelta::Delta(duration),
|
||||
comp_id,
|
||||
});
|
||||
}
|
||||
duration
|
||||
} else if let Some((due, due_tzid)) = todo_dates
|
||||
.into_iter()
|
||||
.filter_map(|(prop, dt, tz_id)| {
|
||||
if prop == &ICalendarProperty::Due {
|
||||
dt.map(|dt| (dt, tz_id))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.next()
|
||||
{
|
||||
let end = due
|
||||
.to_date_time_with_tz(tz_resolver.resolve_or_default(due_tzid.or(dt_start_tzid)))
|
||||
.ok_or(CalendarErrorType::InvalidDtEnd)?;
|
||||
|
||||
if rrule.is_none() {
|
||||
event = Some(CalendarEvent {
|
||||
start: dt_start_tz,
|
||||
end: TimeOrDelta::Time(end),
|
||||
comp_id,
|
||||
});
|
||||
}
|
||||
due.date_time - dt_start.date_time
|
||||
} else {
|
||||
/*
|
||||
For cases where a "VEVENT" calendar component
|
||||
specifies a "DTSTART" property with a DATE value type but no
|
||||
"DTEND" nor "DURATION" property, the event's duration is taken to
|
||||
be one day. For cases where a "VEVENT" calendar component
|
||||
specifies a "DTSTART" property with a DATE-TIME value type but no
|
||||
"DTEND" property, the event ends on the same calendar date and
|
||||
time of day specified by the "DTSTART" property.
|
||||
*/
|
||||
|
||||
let duration = if dt_start_has_time {
|
||||
// If the start has time, we use the same time for the end
|
||||
dt_start
|
||||
.date_time
|
||||
.with_hour(23)
|
||||
.and_then(|dt| dt.with_minute(59))
|
||||
.and_then(|dt| dt.with_second(59))
|
||||
.map(|dt| dt - dt_start.date_time)
|
||||
.unwrap_or_else(|| TimeDelta::days(1))
|
||||
} else {
|
||||
TimeDelta::days(1)
|
||||
};
|
||||
if rrule.is_none() {
|
||||
event = Some(CalendarEvent {
|
||||
start: dt_start_tz,
|
||||
end: TimeOrDelta::Delta(duration),
|
||||
comp_id,
|
||||
});
|
||||
}
|
||||
duration
|
||||
};
|
||||
let rid = if let Some(rid) = rid {
|
||||
rid.to_date_time_with_tz(tz_resolver.resolve_or_default(rid_tzid.or(dt_start_tzid)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Add rdates
|
||||
for (tz_id, rdate) in rdates {
|
||||
if let Some(date_start) =
|
||||
rdate.to_date_time_with_tz(tz_resolver.resolve_or_default(tz_id.or(dt_start_tzid)))
|
||||
{
|
||||
events.push(CalendarEvent {
|
||||
start: date_start,
|
||||
end: TimeOrDelta::Delta(default_duration),
|
||||
comp_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (tz_id, start, end) in rdates_periods {
|
||||
let tz = tz_resolver.resolve_or_default(tz_id.or(dt_start_tzid));
|
||||
if let (Some(date_start), Some(date_end)) = (
|
||||
start.to_date_time_with_tz(tz),
|
||||
end.into_date_time_with_tz(tz),
|
||||
) {
|
||||
events.push(CalendarEvent {
|
||||
start: date_start,
|
||||
end: date_end,
|
||||
comp_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(CalendarEventBuilder {
|
||||
event,
|
||||
dt_start_tzid,
|
||||
default_duration,
|
||||
rrule_seq,
|
||||
rrule,
|
||||
exdates,
|
||||
start_tz,
|
||||
dt_start,
|
||||
rid,
|
||||
rid_this_and_future,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl TimeOrDelta<DateTimeResult, TimeDelta> {
|
||||
pub fn into_date_time_with_tz(self, tz: Tz) -> Option<TimeOrDelta<DateTime<Tz>, TimeDelta>> {
|
||||
match self {
|
||||
TimeOrDelta::Time(time) => time.to_date_time_with_tz(tz).map(TimeOrDelta::Time),
|
||||
TimeOrDelta::Delta(delta) => Some(TimeOrDelta::Delta(delta)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CalendarEvent<DateTime<Tz>, TimeOrDelta<DateTime<Tz>, TimeDelta>> {
|
||||
pub fn timestamps(&self) -> (i64, i64) {
|
||||
let timestamp = self.start.timestamp();
|
||||
let end_timestamp = match self.end {
|
||||
TimeOrDelta::Time(time) => time.timestamp(),
|
||||
TimeOrDelta::Delta(delta) => timestamp + delta.num_seconds(),
|
||||
};
|
||||
|
||||
(timestamp, end_timestamp)
|
||||
}
|
||||
|
||||
pub fn try_into_date_time(self) -> Option<CalendarEvent<DateTime<Tz>, DateTime<Tz>>> {
|
||||
match self.end {
|
||||
TimeOrDelta::Time(time) => Some(time),
|
||||
TimeOrDelta::Delta(delta) => self
|
||||
.start
|
||||
.naive_local()
|
||||
.checked_add_signed(delta)
|
||||
.and_then(|end| end.and_local_timezone(self.start.timezone()).single()),
|
||||
}
|
||||
.map(|end| CalendarEvent {
|
||||
start: self.start,
|
||||
end,
|
||||
comp_id: self.comp_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CalendarErrorType {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CalendarErrorType::MissingDtStart => write!(f, "Missing DTSTART property"),
|
||||
CalendarErrorType::InvalidDtStart => write!(f, "Invalid DTSTART property"),
|
||||
CalendarErrorType::InvalidDtEnd => write!(f, "Invalid DTEND property"),
|
||||
CalendarErrorType::InvalidDuration => write!(f, "Invalid DURATION property"),
|
||||
CalendarErrorType::RRule(err) => write!(f, "RRule error: {err}"),
|
||||
CalendarErrorType::ExpansionBudgetExhausted => {
|
||||
write!(f, "Recurrence expansion stopped: work limit reached")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{
|
||||
Entry, Parser,
|
||||
common::timezone::Tz,
|
||||
icalendar::dates::{CalendarError, CalendarEvent},
|
||||
};
|
||||
use chrono::DateTime;
|
||||
use serde::Serialize;
|
||||
use std::{io::Write, time::Instant};
|
||||
|
||||
#[test]
|
||||
fn expand_rrule() {
|
||||
// Read all .ics files in the test directory
|
||||
for entry in std::fs::read_dir("resources/ical").unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|ext| ext == "ics") {
|
||||
let input = match String::from_utf8(std::fs::read(&path).unwrap()) {
|
||||
Ok(input) => input,
|
||||
Err(err) => {
|
||||
// ISO-8859-1
|
||||
err.as_bytes()
|
||||
.iter()
|
||||
.map(|&b| b as char)
|
||||
.collect::<String>()
|
||||
}
|
||||
};
|
||||
let mut parser = Parser::new(&input);
|
||||
let mut output = None;
|
||||
//let mut output_debug =
|
||||
// std::fs::File::create(path.with_extension("ics.debug")).unwrap();
|
||||
let file_name = path.as_path().to_str().unwrap();
|
||||
|
||||
/*if file_name != "resources/ical/246.ics" {
|
||||
continue;
|
||||
}*/
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TestResult {
|
||||
errors: Vec<CalendarError>,
|
||||
events: Vec<CalendarEvent<DateTime<Tz>, DateTime<Tz>>>,
|
||||
}
|
||||
|
||||
print!("Expanding recurrences for {file_name}... ");
|
||||
let now = Instant::now();
|
||||
loop {
|
||||
match parser.entry() {
|
||||
Entry::ICalendar(ical) => {
|
||||
let expanded = ical.expand_dates(chrono_tz::Tz::Pacific__Auckland, 100);
|
||||
let mut events = expanded
|
||||
.events
|
||||
.into_iter()
|
||||
.filter_map(|event| event.try_into_date_time())
|
||||
.collect::<Vec<_>>();
|
||||
events.sort_by_key(|a| (a.start, a.comp_id));
|
||||
|
||||
for err in &expanded.errors {
|
||||
print!("[{}: {:?}] ", err.comp_id, err.error);
|
||||
}
|
||||
|
||||
if !events.is_empty() || !expanded.errors.is_empty() {
|
||||
writeln!(
|
||||
output.get_or_insert_with(|| std::fs::File::create(
|
||||
path.with_extension("json")
|
||||
)
|
||||
.unwrap()),
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&TestResult {
|
||||
errors: expanded.errors,
|
||||
events,
|
||||
})
|
||||
.unwrap()
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Entry::InvalidLine(_) => {}
|
||||
Entry::Eof => {
|
||||
println!(" (done in {:?})", now.elapsed());
|
||||
break;
|
||||
}
|
||||
other => {
|
||||
panic!("Expected iCal, got {other:?} for {file_name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: one budget bounds the work of all the rules in a calendar
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use crate::{
|
||||
datecalc::{MAX_EXPANSION_WORK, rrule_iter::ITERATIONS},
|
||||
icalendar::{
|
||||
ICalendar,
|
||||
dates::{CalendarErrorType, CalendarExpand},
|
||||
},
|
||||
};
|
||||
|
||||
fn calendar(rule: &str, components: usize) -> ICalendar {
|
||||
let mut ical = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n".to_string();
|
||||
for _ in 0..components {
|
||||
ical.push_str(&format!(
|
||||
"BEGIN:VEVENT\r\nUID:budget\r\nDTSTART:20000101T000000Z\r\n\
|
||||
DTEND:20000101T010000Z\r\nRRULE:{rule}\r\nEND:VEVENT\r\n"
|
||||
));
|
||||
}
|
||||
ical.push_str("END:VCALENDAR\r\n");
|
||||
ICalendar::parse(&ical).unwrap()
|
||||
}
|
||||
|
||||
// Expands and returns the result with the passes and skipped times it took
|
||||
fn expand(rule: &str, components: usize, limit: usize) -> (CalendarExpand, u64) {
|
||||
let ical = calendar(rule, components);
|
||||
ITERATIONS.with(|count| count.set(0));
|
||||
let expanded = ical.expand_dates(chrono_tz::Tz::UTC, limit);
|
||||
(expanded, ITERATIONS.with(|count| count.get()))
|
||||
}
|
||||
|
||||
// The budget, and the pass that runs over it
|
||||
fn within_budget() -> u64 {
|
||||
u64::from(MAX_EXPANSION_WORK) + 10_000
|
||||
}
|
||||
|
||||
fn stopped(expanded: &CalendarExpand) -> bool {
|
||||
expanded
|
||||
.errors
|
||||
.iter()
|
||||
.any(|err| err.error == CalendarErrorType::ExpansionBudgetExhausted)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_matching_rules_share_one_budget() {
|
||||
// Each rule alone spends calcard's 100,000 passes looking for a 31 February
|
||||
let (expanded, iterations) = expand("FREQ=DAILY;BYMONTH=2;BYMONTHDAY=31", 40, 3000);
|
||||
println!("40 never-matching rules: {iterations} iterations");
|
||||
assert!(iterations <= within_budget(), "{iterations}");
|
||||
assert!(expanded.events.is_empty());
|
||||
assert!(stopped(&expanded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_rules_share_one_budget() {
|
||||
// One instance every four years, each found after ~1,460 daily passes,
|
||||
// and one a year found by skipping every other minute of the year
|
||||
for rule in [
|
||||
"FREQ=DAILY;BYMONTH=2;BYMONTHDAY=29",
|
||||
"FREQ=MINUTELY;BYMONTH=1;BYMONTHDAY=1;BYHOUR=1;BYMINUTE=1",
|
||||
] {
|
||||
let (expanded, iterations) = expand(rule, 1, 3000);
|
||||
println!(
|
||||
"{rule}: {} instances, {iterations} iterations",
|
||||
expanded.events.len()
|
||||
);
|
||||
assert!(iterations <= within_budget(), "{rule}: {iterations}");
|
||||
assert!(!expanded.events.is_empty(), "{rule}");
|
||||
assert!(stopped(&expanded), "{rule}");
|
||||
}
|
||||
|
||||
// A year scanned for nothing costs more than a day scanned for nothing
|
||||
let (expanded, iterations) = expand("FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=31", 20, 3000);
|
||||
println!("20 never-matching yearly rules: {iterations} passes");
|
||||
assert!(iterations < 100_000, "{iterations}");
|
||||
assert!(stopped(&expanded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_rules_expand_in_full() {
|
||||
for (rule, instances) in [
|
||||
("FREQ=DAILY", 3000),
|
||||
("FREQ=WEEKLY;BYDAY=MO,WE,FR", 3000),
|
||||
("FREQ=MONTHLY;BYDAY=-1FR", 3000),
|
||||
("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1", 3000),
|
||||
("FREQ=YEARLY", 3000),
|
||||
// every leap day to the year 10000
|
||||
("FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29", 1941),
|
||||
("FREQ=HOURLY;BYHOUR=9,17", 3000),
|
||||
("FREQ=MINUTELY;INTERVAL=15;BYHOUR=9,10,11", 3000),
|
||||
] {
|
||||
let (expanded, _) = expand(rule, 1, 3000);
|
||||
assert_eq!(expanded.events.len(), instances, "{rule}");
|
||||
assert!(expanded.errors.is_empty(), "{rule}: {:?}", expanded.errors);
|
||||
}
|
||||
|
||||
// Rules after the instance limit is reached cost nothing
|
||||
let (expanded, iterations) = expand("FREQ=DAILY", 200, 3000);
|
||||
assert_eq!(expanded.events.len(), 3000);
|
||||
assert!(expanded.errors.is_empty());
|
||||
assert!(iterations < 4000, "{iterations}");
|
||||
}
|
||||
}
|
||||
Vendored
+902
@@ -0,0 +1,902 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use crate::common::{
|
||||
CalendarScale, Data, IanaParse, IanaString, IanaType, LinkRelation, PartialDateTime,
|
||||
};
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
pub mod builder;
|
||||
pub mod dates;
|
||||
pub mod parser;
|
||||
pub mod timezone;
|
||||
pub mod types;
|
||||
pub mod utils;
|
||||
pub mod writer;
|
||||
|
||||
#[cfg(feature = "rkyv")]
|
||||
pub mod rkyv_timezone;
|
||||
#[cfg(feature = "rkyv")]
|
||||
pub mod rkyv_types;
|
||||
#[cfg(feature = "rkyv")]
|
||||
pub mod rkyv_utils;
|
||||
#[cfg(feature = "rkyv")]
|
||||
pub mod rkyv_writer;
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))]
|
||||
pub struct ICalendar {
|
||||
pub components: Vec<ICalendarComponent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))]
|
||||
pub struct ICalendarComponent {
|
||||
pub component_type: ICalendarComponentType,
|
||||
pub entries: Vec<ICalendarEntry>,
|
||||
pub component_ids: Vec<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))]
|
||||
pub struct ICalendarEntry {
|
||||
pub name: ICalendarProperty,
|
||||
pub params: Vec<ICalendarParameter>,
|
||||
pub values: Vec<ICalendarValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, PartialOrd)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))]
|
||||
pub enum ICalendarValue {
|
||||
Binary(Vec<u8>),
|
||||
Boolean(bool),
|
||||
Uri(Uri),
|
||||
PartialDateTime(Box<PartialDateTime>),
|
||||
Duration(ICalendarDuration),
|
||||
RecurrenceRule(Box<ICalendarRecurrenceRule>),
|
||||
Period(ICalendarPeriod),
|
||||
Float(f64),
|
||||
Integer(i64),
|
||||
Text(String),
|
||||
CalendarScale(CalendarScale),
|
||||
Method(ICalendarMethod),
|
||||
Classification(ICalendarClassification),
|
||||
Status(ICalendarStatus),
|
||||
Transparency(ICalendarTransparency),
|
||||
Action(ICalendarAction),
|
||||
BusyType(ICalendarFreeBusyType),
|
||||
ParticipantType(ICalendarParticipantType),
|
||||
ResourceType(ICalendarResourceType),
|
||||
Proximity(ICalendarProximityValue),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub struct ICalendarRecurrenceRule {
|
||||
pub freq: ICalendarFrequency,
|
||||
pub until: Option<PartialDateTime>,
|
||||
pub count: Option<u32>,
|
||||
pub interval: Option<u16>,
|
||||
pub bysecond: Vec<u8>,
|
||||
pub byminute: Vec<u8>,
|
||||
pub byhour: Vec<u8>,
|
||||
pub byday: Vec<ICalendarDay>,
|
||||
pub bymonthday: Vec<i8>,
|
||||
pub byyearday: Vec<i16>,
|
||||
pub byweekno: Vec<i8>,
|
||||
pub bymonth: Vec<ICalendarMonth>,
|
||||
pub bysetpos: Vec<i32>,
|
||||
pub wkst: Option<ICalendarWeekday>,
|
||||
pub rscale: Option<CalendarScale>,
|
||||
pub skip: Option<ICalendarSkip>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
#[repr(u8)]
|
||||
pub enum ICalendarSkip {
|
||||
Omit,
|
||||
Backward,
|
||||
Forward,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub struct ICalendarDay {
|
||||
pub ordwk: Option<i16>,
|
||||
pub weekday: ICalendarWeekday,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
#[repr(transparent)]
|
||||
pub struct ICalendarMonth(i8);
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
#[repr(u8)]
|
||||
pub enum ICalendarFrequency {
|
||||
Yearly = 0,
|
||||
Monthly = 1,
|
||||
Weekly = 2,
|
||||
#[default]
|
||||
Daily = 3,
|
||||
Hourly = 4,
|
||||
Minutely = 5,
|
||||
Secondly = 6,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarWeekday {
|
||||
Sunday,
|
||||
Monday,
|
||||
Tuesday,
|
||||
Wednesday,
|
||||
Thursday,
|
||||
Friday,
|
||||
Saturday,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarPeriod {
|
||||
Range {
|
||||
start: PartialDateTime,
|
||||
end: PartialDateTime,
|
||||
},
|
||||
Duration {
|
||||
start: PartialDateTime,
|
||||
duration: ICalendarDuration,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarAction {
|
||||
Audio, // [RFC5545, Section 3.8.6.1]
|
||||
Display, // [RFC5545, Section 3.8.6.1]
|
||||
Email, // [RFC5545, Section 3.8.6.1]
|
||||
Procedure, // [RFC2445, Section 4.8.6.1]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarUserTypes {
|
||||
Individual, // [RFC5545, Section 3.2.3]
|
||||
Group, // [RFC5545, Section 3.2.3]
|
||||
Resource, // [RFC5545, Section 3.2.3]
|
||||
Room, // [RFC5545, Section 3.2.3]
|
||||
Unknown, // [RFC5545, Section 3.2.3]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarClassification {
|
||||
Public, // [RFC5545, Section 3.8.1.3]
|
||||
Private, // [RFC5545, Section 3.8.1.3]
|
||||
Confidential, // [RFC5545, Section 3.8.1.3]
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarComponentType {
|
||||
Other(String),
|
||||
#[default]
|
||||
VCalendar, // [RFC5545, Section 3.4]
|
||||
VEvent, // [RFC5545, Section 3.6.1]
|
||||
VTodo, // [RFC5545, Section 3.6.2]
|
||||
VJournal, // [RFC5545, Section 3.6.3]
|
||||
VFreebusy, // [RFC5545, Section 3.6.4]
|
||||
VTimezone, // [RFC5545, Section 3.6.5]
|
||||
VAlarm, // [RFC5545, Section 3.6.6]
|
||||
Standard, // [RFC5545, Section 3.6.5]
|
||||
Daylight, // [RFC5545, Section 3.6.5]
|
||||
VAvailability, // [RFC7953, Section 3.1]
|
||||
Available, // [RFC7953, Section 3.1]
|
||||
Participant, // [RFC9073, Section 7.1]
|
||||
VLocation, // [RFC9073, Section 7.2] [RFC Errata 7381]
|
||||
VResource, // [RFC9073, Section 7.3]
|
||||
VStatus, // draft-ietf-calext-ical-tasks-14
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarDisplayType {
|
||||
Badge, // [RFC7986, Section 6.1]
|
||||
Graphic, // [RFC7986, Section 6.1]
|
||||
Fullsize, // [RFC7986, Section 6.1]
|
||||
Thumbnail, // [RFC7986, Section 6.1]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarFeatureType {
|
||||
Audio, // [RFC7986, Section 6.3]
|
||||
Chat, // [RFC7986, Section 6.3]
|
||||
Feed, // [RFC7986, Section 6.3]
|
||||
Moderator, // [RFC7986, Section 6.3]
|
||||
Phone, // [RFC7986, Section 6.3]
|
||||
Screen, // [RFC7986, Section 6.3]
|
||||
Video, // [RFC7986, Section 6.3]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarFreeBusyType {
|
||||
Free, // [RFC5545, Section 3.2.9]
|
||||
Busy, // [RFC5545, Section 3.2.9]
|
||||
BusyUnavailable, // [RFC5545, Section 3.2.9]
|
||||
BusyTentative, // [RFC5545, Section 3.2.9]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarMethod {
|
||||
Publish, // [RFC5546]
|
||||
Request, // [RFC5546]
|
||||
Reply, // [RFC5546]
|
||||
Add, // [RFC5546]
|
||||
Cancel, // [RFC5546]
|
||||
Refresh, // [RFC5546]
|
||||
Counter, // [RFC5546]
|
||||
Declinecounter, // [RFC5546]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))]
|
||||
pub struct ICalendarParameter {
|
||||
pub name: ICalendarParameterName,
|
||||
pub value: ICalendarParameterValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq)))]
|
||||
pub enum ICalendarParameterValue {
|
||||
Text(String),
|
||||
Integer(u64),
|
||||
Bool(bool),
|
||||
Uri(Uri),
|
||||
Cutype(ICalendarUserTypes),
|
||||
Fbtype(ICalendarFreeBusyType),
|
||||
Partstat(ICalendarParticipationStatus),
|
||||
Related(ICalendarRelated),
|
||||
Reltype(ICalendarRelationshipType),
|
||||
Role(ICalendarParticipationRole),
|
||||
ScheduleAgent(ICalendarScheduleAgentValue),
|
||||
ScheduleForceSend(ICalendarScheduleForceSendValue),
|
||||
Value(ICalendarValueType),
|
||||
Display(ICalendarDisplayType),
|
||||
Feature(ICalendarFeatureType),
|
||||
Duration(ICalendarDuration),
|
||||
Linkrel(LinkRelation),
|
||||
Null,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(PartialEq)))]
|
||||
pub enum ICalendarParameterName {
|
||||
Other(String),
|
||||
Altrep, // [RFC5545, Section 3.2.1]
|
||||
Cn, // [RFC5545, Section 3.2.2]
|
||||
Cutype, // [RFC5545, Section 3.2.3]
|
||||
DelegatedFrom, // [RFC5545, Section 3.2.4]
|
||||
DelegatedTo, // [RFC5545, Section 3.2.5]
|
||||
Dir, // [RFC5545, Section 3.2.6]
|
||||
Fmttype, // [RFC5545, Section 3.2.8]
|
||||
Fbtype, // [RFC5545, Section 3.2.9]
|
||||
Language, // [RFC5545, Section 3.2.10]
|
||||
Member, // [RFC5545, Section 3.2.11]
|
||||
Partstat, // [RFC5545, Section 3.2.12]
|
||||
Range, // [RFC5545, Section 3.2.13]
|
||||
Related, // [RFC5545, Section 3.2.14]
|
||||
Reltype, // [RFC5545, Section 3.2.15]
|
||||
Role, // [RFC5545, Section 3.2.16]
|
||||
Rsvp, // [RFC5545, Section 3.2.17]
|
||||
ScheduleAgent, // [RFC6638, Section 7.1]
|
||||
ScheduleForceSend, // [RFC6638, Section 7.2]
|
||||
ScheduleStatus, // [RFC6638, Section 7.3]
|
||||
SentBy, // [RFC5545, Section 3.2.18]
|
||||
Tzid, // [RFC5545, Section 3.2.19]
|
||||
Value, // [RFC5545, Section 3.2.20]
|
||||
Display, // [RFC7986, Section 6.1]
|
||||
Email, // [RFC7986, Section 6.2]
|
||||
Feature, // [RFC7986, Section 6.3]
|
||||
Label, // [RFC7986, Section 6.4]
|
||||
Size, // [RFC8607, Section 4.1]
|
||||
Filename, // [RFC8607, Section 4.2]
|
||||
ManagedId, // [RFC8607, Section 4.3]
|
||||
Order, // [RFC9073, Section 5.1]
|
||||
Schema, // [RFC9073, Section 5.2]
|
||||
Derived, // [RFC9073, Section 5.3]
|
||||
Gap, // [RFC9253, Section 6.2]
|
||||
Linkrel, // [RFC9253, Section 6.1]
|
||||
Jsptr, // draft-ietf-calext-jscalendar-icalendar
|
||||
Jsid, // draft-ietf-calext-jscalendar-icalendar
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub struct ICalendarDuration {
|
||||
pub neg: bool,
|
||||
pub weeks: u32,
|
||||
pub days: u32,
|
||||
pub hours: u32,
|
||||
pub minutes: u32,
|
||||
pub seconds: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum Uri {
|
||||
Data(Data),
|
||||
Location(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarRelated {
|
||||
Start,
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarParticipantType {
|
||||
Active, // [RFC9073, Section 6.2]
|
||||
Inactive, // [RFC9073, Section 6.2]
|
||||
Sponsor, // [RFC9073, Section 6.2]
|
||||
Contact, // [RFC9073, Section 6.2]
|
||||
BookingContact, // [RFC9073, Section 6.2]
|
||||
EmergencyContact, // [RFC9073, Section 6.2]
|
||||
PublicityContact, // [RFC9073, Section 6.2]
|
||||
PlannerContact, // [RFC9073, Section 6.2]
|
||||
Performer, // [RFC9073, Section 6.2]
|
||||
Speaker, // [RFC9073, Section 6.2]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarParticipationRole {
|
||||
Chair, // [RFC5545, Section 3.2.16]
|
||||
ReqParticipant, // [RFC5545, Section 3.2.16]
|
||||
OptParticipant, // [RFC5545, Section 3.2.16]
|
||||
NonParticipant, // [RFC5545, Section 3.2.16]
|
||||
Owner, // JSCalendar
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarStatus {
|
||||
Tentative, // [RFC5545, Section 3.8.1]
|
||||
Confirmed, // [RFC5545, Section 3.8.1]
|
||||
Cancelled, // [RFC5545, Section 3.8.1]
|
||||
NeedsAction, // [RFC5545, Section 3.8.1]
|
||||
Completed, // [RFC5545, Section 3.8.1]
|
||||
InProcess, // [RFC5545, Section 3.8.1]
|
||||
Draft, // [RFC5545, Section 3.8.1]
|
||||
Final, // [RFC5545, Section 3.8.1]
|
||||
Failed, // draft-ietf-calext-ical-tasks
|
||||
Pending, // draft-ietf-calext-ical-tasks
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarParticipationStatus {
|
||||
NeedsAction, // [RFC5545, Section 3.2.12]
|
||||
Accepted, // [RFC5545, Section 3.2.12]
|
||||
Declined, // [RFC5545, Section 3.2.12]
|
||||
Tentative, // [RFC5545, Section 3.2.12]
|
||||
Delegated, // [RFC5545, Section 3.2.12]
|
||||
Completed, // [RFC5545, Section 3.2.12]
|
||||
InProcess, // [RFC5545, Section 3.2.12]
|
||||
Failed, // draft-ietf-calext-ical-tasks
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarProperty {
|
||||
Begin,
|
||||
End,
|
||||
Other(String),
|
||||
Calscale, // [RFC5545, Section 3.7.1]
|
||||
Method, // [RFC5545, Section 3.7.2]
|
||||
Prodid, // [RFC5545, Section 3.7.3]
|
||||
Version, // [RFC5545, Section 3.7.4]
|
||||
Attach, // [RFC5545, Section 3.8.1.1]
|
||||
Categories, // [RFC5545, Section 3.8.1.2] [RFC7986, Section 5.6]
|
||||
Class, // [RFC5545, Section 3.8.1.3]
|
||||
Comment, // [RFC5545, Section 3.8.1.4]
|
||||
Description, // [RFC5545, Section 3.8.1.5] [RFC7986, Section 5.2]
|
||||
Geo, // [RFC5545, Section 3.8.1.6]
|
||||
Location, // [RFC5545, Section 3.8.1.7]
|
||||
PercentComplete, // [RFC5545, Section 3.8.1.8]
|
||||
Priority, // [RFC5545, Section 3.8.1.9]
|
||||
Resources, // [RFC5545, Section 3.8.1.10]
|
||||
Status, // [RFC5545, Section 3.8.1.11]
|
||||
Summary, // [RFC5545, Section 3.8.1.12]
|
||||
Completed, // [RFC5545, Section 3.8.2.1]
|
||||
Dtend, // [RFC5545, Section 3.8.2.2]
|
||||
Due, // [RFC5545, Section 3.8.2.3]
|
||||
Dtstart, // [RFC5545, Section 3.8.2.4]
|
||||
Duration, // [RFC5545, Section 3.8.2.5]
|
||||
Freebusy, // [RFC5545, Section 3.8.2.6]
|
||||
Transp, // [RFC5545, Section 3.8.2.7]
|
||||
Tzid, // [RFC5545, Section 3.8.3.1]
|
||||
Tzname, // [RFC5545, Section 3.8.3.2]
|
||||
Tzoffsetfrom, // [RFC5545, Section 3.8.3.3]
|
||||
Tzoffsetto, // [RFC5545, Section 3.8.3.4]
|
||||
Tzurl, // [RFC5545, Section 3.8.3.5]
|
||||
Attendee, // [RFC5545, Section 3.8.4.1]
|
||||
Contact, // [RFC5545, Section 3.8.4.2]
|
||||
Organizer, // [RFC5545, Section 3.8.4.3]
|
||||
RecurrenceId, // [RFC5545, Section 3.8.4.4]
|
||||
RelatedTo, // [RFC5545, Section 3.8.4.5] [RFC9253, Section 9.1]
|
||||
Url, // [RFC5545, Section 3.8.4.6] [RFC7986, Section 5.5]
|
||||
Uid, // [RFC5545, Section 3.8.4.7] [RFC7986, Section 5.3]
|
||||
Exdate, // [RFC5545, Section 3.8.5.1]
|
||||
Exrule, // [RFC2445, Section 4.8.5.2]
|
||||
Rdate, // [RFC5545, Section 3.8.5.2]
|
||||
Rrule, // [RFC5545, Section 3.8.5.3]
|
||||
Action, // [RFC5545, Section 3.8.6.1]
|
||||
Repeat, // [RFC5545, Section 3.8.6.2]
|
||||
Trigger, // [RFC5545, Section 3.8.6.3]
|
||||
Created, // [RFC5545, Section 3.8.7.1]
|
||||
Dtstamp, // [RFC5545, Section 3.8.7.2]
|
||||
LastModified, // [RFC5545, Section 3.8.7.3] [RFC7986, Section 5.4]
|
||||
Sequence, // [RFC5545, Section 3.8.7.4]
|
||||
RequestStatus, // [RFC5545, Section 3.8.8.3]
|
||||
Xml, // [RFC6321, Section 4.2]
|
||||
Tzuntil, // [RFC7808, Section 7.1]
|
||||
TzidAliasOf, // [RFC7808, Section 7.2]
|
||||
Busytype, // [RFC7953, Section 3.2]
|
||||
Name, // [RFC7986, Section 5.1]
|
||||
RefreshInterval, // [RFC7986, Section 5.7]
|
||||
Source, // [RFC7986, Section 5.8]
|
||||
Color, // [RFC7986, Section 5.9]
|
||||
Image, // [RFC7986, Section 5.10]
|
||||
Conference, // [RFC7986, Section 5.11]
|
||||
CalendarAddress, // [RFC9073, Section 6.4]
|
||||
LocationType, // [RFC9073, Section 6.1]
|
||||
ParticipantType, // [RFC9073, Section 6.2]
|
||||
ResourceType, // [RFC9073, Section 6.3]
|
||||
StructuredData, // [RFC9073, Section 6.6]
|
||||
StyledDescription, // [RFC9073, Section 6.5]
|
||||
Acknowledged, // [RFC9074, Section 6.1]
|
||||
Proximity, // [RFC9074, Section 8.1]
|
||||
Concept, // [RFC9253, Section 8.1]
|
||||
Link, // [RFC9253, Section 8.2]
|
||||
Refid, // [RFC9253, Section 8.3]
|
||||
Coordinates, // draft-ietf-calext-icalendar-jscalendar-extensions
|
||||
ShowWithoutTime, // draft-ietf-calext-icalendar-jscalendar-extensions
|
||||
Jsid, // draft-ietf-calext-jscalendar-icalendar
|
||||
Jsprop, // draft-ietf-calext-jscalendar-icalendar
|
||||
EstimatedDuration, // draft-ietf-calext-ical-tasks
|
||||
Reason, // draft-ietf-calext-ical-tasks
|
||||
Substate, // draft-ietf-calext-ical-tasks
|
||||
TaskMode, // draft-ietf-calext-ical-tasks
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarProximityValue {
|
||||
Arrive, // [RFC9074, Section 8.1]
|
||||
Depart, // [RFC9074, Section 8.1]
|
||||
Connect, // [RFC9074, Section 8.1]
|
||||
Disconnect, // [RFC9074, Section 8.1]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarRelationshipType {
|
||||
Child, // [RFC5545, Section 3.2.15]
|
||||
Parent, // [RFC5545, Section 3.2.15]
|
||||
Sibling, // [RFC5545, Section 3.2.15]
|
||||
Snooze, // [RFC9074, Section 7.1]
|
||||
Concept, // [RFC9253, Section 5]
|
||||
DependsOn, // [RFC9253, Section 5]
|
||||
Finishtofinish, // [RFC9253, Section 4]
|
||||
Finishtostart, // [RFC9253, Section 4]
|
||||
First, // [RFC9253, Section 5]
|
||||
Next, // [RFC9253, Section 5]
|
||||
Refid, // [RFC9253, Section 5]
|
||||
Starttofinish, // [RFC9253, Section 4]
|
||||
Starttostart, // [RFC9253, Section 4]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarResourceType {
|
||||
Projector, // [RFC9073, Section 6.3]
|
||||
Room, // [RFC9073, Section 6.3]
|
||||
RemoteConferenceAudio, // [RFC9073, Section 6.3]
|
||||
RemoteConferenceVideo, // [RFC9073, Section 6.3]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarScheduleAgentValue {
|
||||
Server, // [RFC6638, Section 7.1]
|
||||
Client, // [RFC6638, Section 7.1]
|
||||
None, // [RFC6638, Section 7.1]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarScheduleForceSendValue {
|
||||
Request, // [RFC6638, Section 7.2]
|
||||
Reply, // [RFC6638, Section 7.2]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarValueType {
|
||||
Binary, // [RFC5545, Section 3.3.1]
|
||||
Boolean, // [RFC5545, Section 3.3.2]
|
||||
CalAddress, // [RFC5545, Section 3.3.3]
|
||||
Date, // [RFC5545, Section 3.3.4]
|
||||
DateTime, // [RFC5545, Section 3.3.5]
|
||||
Duration, // [RFC5545, Section 3.3.6]
|
||||
Float, // [RFC5545, Section 3.3.7]
|
||||
Integer, // [RFC5545, Section 3.3.8]
|
||||
Period, // [RFC5545, Section 3.3.9]
|
||||
Recur, // [RFC5545, Section 3.3.10]
|
||||
Text, // [RFC5545, Section 3.3.11]
|
||||
Time, // [RFC5545, Section 3.3.12]
|
||||
Unknown, // [RFC7265, Section 5]
|
||||
Uri, // [RFC5545, Section 3.3.13]
|
||||
UtcOffset, // [RFC5545, Section 3.3.14]
|
||||
XmlReference, // [RFC9253, Section 7]
|
||||
Uid, // [RFC9253, Section 7]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(
|
||||
any(test, feature = "serde"),
|
||||
derive(serde::Serialize, serde::Deserialize)
|
||||
)]
|
||||
#[cfg_attr(any(test, feature = "serde"), serde(tag = "type", content = "data"))]
|
||||
#[cfg_attr(
|
||||
feature = "rkyv",
|
||||
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
||||
)]
|
||||
#[cfg_attr(feature = "rkyv", rkyv(compare(PartialEq), derive(Debug)))]
|
||||
pub enum ICalendarTransparency {
|
||||
Opaque,
|
||||
Transparent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ValueSeparator {
|
||||
None,
|
||||
Comma,
|
||||
Semicolon,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ValueType {
|
||||
Ical(ICalendarValueType),
|
||||
CalendarScale,
|
||||
Method,
|
||||
Classification,
|
||||
Status,
|
||||
Transparency,
|
||||
Action,
|
||||
BusyType,
|
||||
ParticipantType,
|
||||
ResourceType,
|
||||
Proximity,
|
||||
}
|
||||
+2165
File diff suppressed because it is too large.
Load diff
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::{
|
||||
ArchivedICalendar, ArchivedICalendarComponent, ArchivedICalendarComponentType,
|
||||
ArchivedICalendarEntry, ArchivedICalendarProperty, ArchivedICalendarValue,
|
||||
};
|
||||
use crate::{common::timezone::Tz, icalendar::ICalendarParameterName};
|
||||
use std::str::FromStr;
|
||||
|
||||
impl ArchivedICalendar {
|
||||
pub fn timezones(&self) -> impl Iterator<Item = &ArchivedICalendarComponent> {
|
||||
self.components.iter().filter(|comp| {
|
||||
matches!(
|
||||
comp.component_type,
|
||||
ArchivedICalendarComponentType::VTimezone
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_timezone(&self) -> bool {
|
||||
self.timezones().count() == 1
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarComponent {
|
||||
pub fn timezone(&self) -> Option<(&str, Tz)> {
|
||||
let mut tz_name = None;
|
||||
let mut tz_id = None;
|
||||
let mut tz_lic = None;
|
||||
let mut tz_cdo_id = None;
|
||||
|
||||
for entry in self.entries.iter() {
|
||||
match (&entry.name, entry.values.first()) {
|
||||
(ArchivedICalendarProperty::Tzid, Some(ArchivedICalendarValue::Text(id))) => {
|
||||
tz_id = Tz::from_str(id.as_str()).ok();
|
||||
tz_name = Some(id.as_str());
|
||||
}
|
||||
(
|
||||
ArchivedICalendarProperty::Other(value),
|
||||
Some(ArchivedICalendarValue::Text(id)),
|
||||
) => {
|
||||
hashify::fnc_map!(value.as_bytes(),
|
||||
"X-LIC-LOCATION" => {
|
||||
tz_lic = Tz::from_str(id.strip_prefix("SystemV/").unwrap_or(id.as_str())).ok();
|
||||
},
|
||||
"X-MICROSOFT-CDO-TZID" => {
|
||||
tz_cdo_id = Tz::from_ms_cdo_zone_id(id);
|
||||
},
|
||||
_ => {}
|
||||
);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
tz_name.zip(tz_id.or(tz_lic).or(tz_cdo_id))
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarEntry {
|
||||
pub fn tz_id(&self) -> Option<&str> {
|
||||
self.parameters(&ICalendarParameterName::Tzid)
|
||||
.find_map(|param| param.as_text())
|
||||
}
|
||||
}
|
||||
+871
@@ -0,0 +1,871 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use crate::common::ArchivedLinkRelation;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl ArchivedICalendarProperty {
|
||||
// Returns the default value type and whether the property is multi-valued.
|
||||
pub(crate) fn default_types(&self) -> (ArchivedValueType, ValueSeparator) {
|
||||
match self {
|
||||
ArchivedICalendarProperty::Calscale => {
|
||||
(ArchivedValueType::CalendarScale, ValueSeparator::None)
|
||||
}
|
||||
ArchivedICalendarProperty::Method => (ArchivedValueType::Method, ValueSeparator::None),
|
||||
ArchivedICalendarProperty::Prodid => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Version => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Attach => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Uri),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Categories => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::Comma,
|
||||
),
|
||||
ArchivedICalendarProperty::Class => {
|
||||
(ArchivedValueType::Classification, ValueSeparator::None)
|
||||
}
|
||||
ArchivedICalendarProperty::Comment => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Description => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Geo => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Float),
|
||||
ValueSeparator::Semicolon,
|
||||
),
|
||||
ArchivedICalendarProperty::Location => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::PercentComplete => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Integer),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Priority => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Integer),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Resources => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::Comma,
|
||||
),
|
||||
ArchivedICalendarProperty::Status => (ArchivedValueType::Status, ValueSeparator::None),
|
||||
ArchivedICalendarProperty::Summary => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Completed => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Dtend => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Due => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Dtstart => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Duration => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Duration),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Freebusy => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Period),
|
||||
ValueSeparator::Comma,
|
||||
),
|
||||
ArchivedICalendarProperty::Transp => {
|
||||
(ArchivedValueType::Transparency, ValueSeparator::None)
|
||||
}
|
||||
ArchivedICalendarProperty::Tzid => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Tzname => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Tzoffsetfrom => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::UtcOffset),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Tzoffsetto => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::UtcOffset),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Tzurl => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Uri),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Attendee => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Uri),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Contact => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Organizer => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Uri),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::RecurrenceId => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::RelatedTo => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Url => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Uri),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Uid => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Exdate => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime),
|
||||
ValueSeparator::Comma,
|
||||
),
|
||||
ArchivedICalendarProperty::Exrule => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Recur),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Rdate => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime),
|
||||
ValueSeparator::Comma,
|
||||
),
|
||||
ArchivedICalendarProperty::Rrule => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Recur),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Action => (ArchivedValueType::Action, ValueSeparator::None),
|
||||
ArchivedICalendarProperty::Repeat => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Integer),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Trigger => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Duration),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Created => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Dtstamp => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::LastModified => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Sequence => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Integer),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::RequestStatus => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::Semicolon,
|
||||
),
|
||||
ArchivedICalendarProperty::Xml => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Tzuntil => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::TzidAliasOf => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Busytype => {
|
||||
(ArchivedValueType::BusyType, ValueSeparator::None)
|
||||
}
|
||||
ArchivedICalendarProperty::Name => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::RefreshInterval => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Duration),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Source => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Uri),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Color => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Image => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Uri),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Conference => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Uri),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::CalendarAddress => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Uri),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::LocationType => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::Comma,
|
||||
),
|
||||
ArchivedICalendarProperty::ParticipantType => {
|
||||
(ArchivedValueType::ParticipantType, ValueSeparator::None)
|
||||
}
|
||||
ArchivedICalendarProperty::ResourceType => {
|
||||
(ArchivedValueType::ResourceType, ValueSeparator::None)
|
||||
}
|
||||
ArchivedICalendarProperty::StructuredData => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::StyledDescription => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Acknowledged => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::DateTime),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Proximity => {
|
||||
(ArchivedValueType::Proximity, ValueSeparator::None)
|
||||
}
|
||||
ArchivedICalendarProperty::Concept => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Uri),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Link => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Uri),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Refid => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Begin => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::End => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Other(_) => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::Semicolon,
|
||||
),
|
||||
ArchivedICalendarProperty::Coordinates => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Uri),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::ShowWithoutTime => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Boolean),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Jsid | ArchivedICalendarProperty::Jsprop => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::EstimatedDuration => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Duration),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Reason => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Uri),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::Substate => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
ArchivedICalendarProperty::TaskMode => (
|
||||
ArchivedValueType::Ical(ArchivedICalendarValueType::Text),
|
||||
ValueSeparator::None,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IanaString for ArchivedICalendarFrequency {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarFrequency::Secondly => "SECONDLY",
|
||||
ArchivedICalendarFrequency::Minutely => "MINUTELY",
|
||||
ArchivedICalendarFrequency::Hourly => "HOURLY",
|
||||
ArchivedICalendarFrequency::Daily => "DAILY",
|
||||
ArchivedICalendarFrequency::Weekly => "WEEKLY",
|
||||
ArchivedICalendarFrequency::Monthly => "MONTHLY",
|
||||
ArchivedICalendarFrequency::Yearly => "YEARLY",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarSkip {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarSkip::Omit => "OMIT",
|
||||
ArchivedICalendarSkip::Backward => "BACKWARD",
|
||||
ArchivedICalendarSkip::Forward => "FORWARD",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarWeekday {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarWeekday::Sunday => "SU",
|
||||
ArchivedICalendarWeekday::Monday => "MO",
|
||||
ArchivedICalendarWeekday::Tuesday => "TU",
|
||||
ArchivedICalendarWeekday::Wednesday => "WE",
|
||||
ArchivedICalendarWeekday::Thursday => "TH",
|
||||
ArchivedICalendarWeekday::Friday => "FR",
|
||||
ArchivedICalendarWeekday::Saturday => "SA",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarAction {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarAction::Audio => "AUDIO",
|
||||
ArchivedICalendarAction::Display => "DISPLAY",
|
||||
ArchivedICalendarAction::Email => "EMAIL",
|
||||
ArchivedICalendarAction::Procedure => "PROCEDURE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarUserTypes {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarUserTypes::Individual => "INDIVIDUAL",
|
||||
ArchivedICalendarUserTypes::Group => "GROUP",
|
||||
ArchivedICalendarUserTypes::Resource => "RESOURCE",
|
||||
ArchivedICalendarUserTypes::Room => "ROOM",
|
||||
ArchivedICalendarUserTypes::Unknown => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarClassification {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarClassification::Public => "PUBLIC",
|
||||
ArchivedICalendarClassification::Private => "PRIVATE",
|
||||
ArchivedICalendarClassification::Confidential => "CONFIDENTIAL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarComponentType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ArchivedICalendarComponentType::VCalendar => "VCALENDAR",
|
||||
ArchivedICalendarComponentType::VEvent => "VEVENT",
|
||||
ArchivedICalendarComponentType::VTodo => "VTODO",
|
||||
ArchivedICalendarComponentType::VJournal => "VJOURNAL",
|
||||
ArchivedICalendarComponentType::VFreebusy => "VFREEBUSY",
|
||||
ArchivedICalendarComponentType::VTimezone => "VTIMEZONE",
|
||||
ArchivedICalendarComponentType::VAlarm => "VALARM",
|
||||
ArchivedICalendarComponentType::Standard => "STANDARD",
|
||||
ArchivedICalendarComponentType::Daylight => "DAYLIGHT",
|
||||
ArchivedICalendarComponentType::VAvailability => "VAVAILABILITY",
|
||||
ArchivedICalendarComponentType::Available => "AVAILABLE",
|
||||
ArchivedICalendarComponentType::Participant => "PARTICIPANT",
|
||||
ArchivedICalendarComponentType::VLocation => "VLOCATION",
|
||||
ArchivedICalendarComponentType::VResource => "VRESOURCE",
|
||||
ArchivedICalendarComponentType::VStatus => "VSTATUS",
|
||||
ArchivedICalendarComponentType::Other(name) => name.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarDisplayType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarDisplayType::Badge => "BADGE",
|
||||
ArchivedICalendarDisplayType::Graphic => "GRAPHIC",
|
||||
ArchivedICalendarDisplayType::Fullsize => "FULLSIZE",
|
||||
ArchivedICalendarDisplayType::Thumbnail => "THUMBNAIL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarFeatureType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarFeatureType::Audio => "AUDIO",
|
||||
ArchivedICalendarFeatureType::Chat => "CHAT",
|
||||
ArchivedICalendarFeatureType::Feed => "FEED",
|
||||
ArchivedICalendarFeatureType::Moderator => "MODERATOR",
|
||||
ArchivedICalendarFeatureType::Phone => "PHONE",
|
||||
ArchivedICalendarFeatureType::Screen => "SCREEN",
|
||||
ArchivedICalendarFeatureType::Video => "VIDEO",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarFreeBusyType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarFreeBusyType::Free => "FREE",
|
||||
ArchivedICalendarFreeBusyType::Busy => "BUSY",
|
||||
ArchivedICalendarFreeBusyType::BusyUnavailable => "BUSY-UNAVAILABLE",
|
||||
ArchivedICalendarFreeBusyType::BusyTentative => "BUSY-TENTATIVE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarMethod {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarMethod::Publish => "PUBLISH",
|
||||
ArchivedICalendarMethod::Request => "REQUEST",
|
||||
ArchivedICalendarMethod::Reply => "REPLY",
|
||||
ArchivedICalendarMethod::Add => "ADD",
|
||||
ArchivedICalendarMethod::Cancel => "CANCEL",
|
||||
ArchivedICalendarMethod::Refresh => "REFRESH",
|
||||
ArchivedICalendarMethod::Counter => "COUNTER",
|
||||
ArchivedICalendarMethod::Declinecounter => "DECLINECOUNTER",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarParticipantType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarParticipantType::Active => "ACTIVE",
|
||||
ArchivedICalendarParticipantType::Inactive => "INACTIVE",
|
||||
ArchivedICalendarParticipantType::Sponsor => "SPONSOR",
|
||||
ArchivedICalendarParticipantType::Contact => "CONTACT",
|
||||
ArchivedICalendarParticipantType::BookingContact => "BOOKING-CONTACT",
|
||||
ArchivedICalendarParticipantType::EmergencyContact => "EMERGENCY-CONTACT",
|
||||
ArchivedICalendarParticipantType::PublicityContact => "PUBLICITY-CONTACT",
|
||||
ArchivedICalendarParticipantType::PlannerContact => "PLANNER-CONTACT",
|
||||
ArchivedICalendarParticipantType::Performer => "PERFORMER",
|
||||
ArchivedICalendarParticipantType::Speaker => "SPEAKER",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarParticipationRole {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarParticipationRole::Chair => "CHAIR",
|
||||
ArchivedICalendarParticipationRole::ReqParticipant => "REQ-PARTICIPANT",
|
||||
ArchivedICalendarParticipationRole::OptParticipant => "OPT-PARTICIPANT",
|
||||
ArchivedICalendarParticipationRole::NonParticipant => "NON-PARTICIPANT",
|
||||
ArchivedICalendarParticipationRole::Owner => "OWNER",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarParticipationStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarParticipationStatus::NeedsAction => "NEEDS-ACTION",
|
||||
ArchivedICalendarParticipationStatus::Accepted => "ACCEPTED",
|
||||
ArchivedICalendarParticipationStatus::Declined => "DECLINED",
|
||||
ArchivedICalendarParticipationStatus::Tentative => "TENTATIVE",
|
||||
ArchivedICalendarParticipationStatus::Delegated => "DELEGATED",
|
||||
ArchivedICalendarParticipationStatus::Completed => "COMPLETED",
|
||||
ArchivedICalendarParticipationStatus::InProcess => "IN-PROCESS",
|
||||
ArchivedICalendarParticipationStatus::Failed => "FAILED",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarStatus::Tentative => "TENTATIVE",
|
||||
ArchivedICalendarStatus::Confirmed => "CONFIRMED",
|
||||
ArchivedICalendarStatus::Cancelled => "CANCELLED",
|
||||
ArchivedICalendarStatus::NeedsAction => "NEEDS-ACTION",
|
||||
ArchivedICalendarStatus::Completed => "COMPLETED",
|
||||
ArchivedICalendarStatus::InProcess => "IN-PROCESS",
|
||||
ArchivedICalendarStatus::Draft => "DRAFT",
|
||||
ArchivedICalendarStatus::Final => "FINAL",
|
||||
ArchivedICalendarStatus::Failed => "FAILED",
|
||||
ArchivedICalendarStatus::Pending => "PENDING",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarProperty {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ArchivedICalendarProperty::Calscale => "CALSCALE",
|
||||
ArchivedICalendarProperty::Method => "METHOD",
|
||||
ArchivedICalendarProperty::Prodid => "PRODID",
|
||||
ArchivedICalendarProperty::Version => "VERSION",
|
||||
ArchivedICalendarProperty::Attach => "ATTACH",
|
||||
ArchivedICalendarProperty::Categories => "CATEGORIES",
|
||||
ArchivedICalendarProperty::Class => "CLASS",
|
||||
ArchivedICalendarProperty::Comment => "COMMENT",
|
||||
ArchivedICalendarProperty::Description => "DESCRIPTION",
|
||||
ArchivedICalendarProperty::Geo => "GEO",
|
||||
ArchivedICalendarProperty::Location => "LOCATION",
|
||||
ArchivedICalendarProperty::PercentComplete => "PERCENT-COMPLETE",
|
||||
ArchivedICalendarProperty::Priority => "PRIORITY",
|
||||
ArchivedICalendarProperty::Resources => "RESOURCES",
|
||||
ArchivedICalendarProperty::Status => "STATUS",
|
||||
ArchivedICalendarProperty::Summary => "SUMMARY",
|
||||
ArchivedICalendarProperty::Completed => "COMPLETED",
|
||||
ArchivedICalendarProperty::Dtend => "DTEND",
|
||||
ArchivedICalendarProperty::Due => "DUE",
|
||||
ArchivedICalendarProperty::Dtstart => "DTSTART",
|
||||
ArchivedICalendarProperty::Duration => "DURATION",
|
||||
ArchivedICalendarProperty::Freebusy => "FREEBUSY",
|
||||
ArchivedICalendarProperty::Transp => "TRANSP",
|
||||
ArchivedICalendarProperty::Tzid => "TZID",
|
||||
ArchivedICalendarProperty::Tzname => "TZNAME",
|
||||
ArchivedICalendarProperty::Tzoffsetfrom => "TZOFFSETFROM",
|
||||
ArchivedICalendarProperty::Tzoffsetto => "TZOFFSETTO",
|
||||
ArchivedICalendarProperty::Tzurl => "TZURL",
|
||||
ArchivedICalendarProperty::Attendee => "ATTENDEE",
|
||||
ArchivedICalendarProperty::Contact => "CONTACT",
|
||||
ArchivedICalendarProperty::Organizer => "ORGANIZER",
|
||||
ArchivedICalendarProperty::RecurrenceId => "RECURRENCE-ID",
|
||||
ArchivedICalendarProperty::RelatedTo => "RELATED-TO",
|
||||
ArchivedICalendarProperty::Url => "URL",
|
||||
ArchivedICalendarProperty::Uid => "UID",
|
||||
ArchivedICalendarProperty::Exdate => "EXDATE",
|
||||
ArchivedICalendarProperty::Exrule => "EXRULE",
|
||||
ArchivedICalendarProperty::Rdate => "RDATE",
|
||||
ArchivedICalendarProperty::Rrule => "RRULE",
|
||||
ArchivedICalendarProperty::Action => "ACTION",
|
||||
ArchivedICalendarProperty::Repeat => "REPEAT",
|
||||
ArchivedICalendarProperty::Trigger => "TRIGGER",
|
||||
ArchivedICalendarProperty::Created => "CREATED",
|
||||
ArchivedICalendarProperty::Dtstamp => "DTSTAMP",
|
||||
ArchivedICalendarProperty::LastModified => "LAST-MODIFIED",
|
||||
ArchivedICalendarProperty::Sequence => "SEQUENCE",
|
||||
ArchivedICalendarProperty::RequestStatus => "REQUEST-STATUS",
|
||||
ArchivedICalendarProperty::Xml => "XML",
|
||||
ArchivedICalendarProperty::Tzuntil => "TZUNTIL",
|
||||
ArchivedICalendarProperty::TzidAliasOf => "TZID-ALIAS-OF",
|
||||
ArchivedICalendarProperty::Busytype => "BUSYTYPE",
|
||||
ArchivedICalendarProperty::Name => "NAME",
|
||||
ArchivedICalendarProperty::RefreshInterval => "REFRESH-INTERVAL",
|
||||
ArchivedICalendarProperty::Source => "SOURCE",
|
||||
ArchivedICalendarProperty::Color => "COLOR",
|
||||
ArchivedICalendarProperty::Image => "IMAGE",
|
||||
ArchivedICalendarProperty::Conference => "CONFERENCE",
|
||||
ArchivedICalendarProperty::CalendarAddress => "CALENDAR-ADDRESS",
|
||||
ArchivedICalendarProperty::LocationType => "LOCATION-TYPE",
|
||||
ArchivedICalendarProperty::ParticipantType => "PARTICIPANT-TYPE",
|
||||
ArchivedICalendarProperty::ResourceType => "RESOURCE-TYPE",
|
||||
ArchivedICalendarProperty::StructuredData => "STRUCTURED-DATA",
|
||||
ArchivedICalendarProperty::StyledDescription => "STYLED-DESCRIPTION",
|
||||
ArchivedICalendarProperty::Acknowledged => "ACKNOWLEDGED",
|
||||
ArchivedICalendarProperty::Proximity => "PROXIMITY",
|
||||
ArchivedICalendarProperty::Concept => "CONCEPT",
|
||||
ArchivedICalendarProperty::Link => "LINK",
|
||||
ArchivedICalendarProperty::Refid => "REFID",
|
||||
ArchivedICalendarProperty::Begin => "BEGIN",
|
||||
ArchivedICalendarProperty::End => "END",
|
||||
ArchivedICalendarProperty::Coordinates => "COORDINATES",
|
||||
ArchivedICalendarProperty::ShowWithoutTime => "SHOW-WITHOUT-TIME",
|
||||
ArchivedICalendarProperty::Jsid => "JSID",
|
||||
ArchivedICalendarProperty::Jsprop => "JSPROP",
|
||||
ArchivedICalendarProperty::EstimatedDuration => "ESTIMATED-DURATION",
|
||||
ArchivedICalendarProperty::Reason => "REASON",
|
||||
ArchivedICalendarProperty::Substate => "SUBSTATE",
|
||||
ArchivedICalendarProperty::TaskMode => "TASK-MODE",
|
||||
ArchivedICalendarProperty::Other(value) => value.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarProximityValue {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarProximityValue::Arrive => "ARRIVE",
|
||||
ArchivedICalendarProximityValue::Depart => "DEPART",
|
||||
ArchivedICalendarProximityValue::Connect => "CONNECT",
|
||||
ArchivedICalendarProximityValue::Disconnect => "DISCONNECT",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarRelationshipType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarRelationshipType::Child => "CHILD",
|
||||
ArchivedICalendarRelationshipType::Parent => "PARENT",
|
||||
ArchivedICalendarRelationshipType::Sibling => "SIBLING",
|
||||
ArchivedICalendarRelationshipType::Snooze => "SNOOZE",
|
||||
ArchivedICalendarRelationshipType::Concept => "CONCEPT",
|
||||
ArchivedICalendarRelationshipType::DependsOn => "DEPENDS-ON",
|
||||
ArchivedICalendarRelationshipType::Finishtofinish => "FINISHTOFINISH",
|
||||
ArchivedICalendarRelationshipType::Finishtostart => "FINISHTOSTART",
|
||||
ArchivedICalendarRelationshipType::First => "FIRST",
|
||||
ArchivedICalendarRelationshipType::Next => "NEXT",
|
||||
ArchivedICalendarRelationshipType::Refid => "REFID",
|
||||
ArchivedICalendarRelationshipType::Starttofinish => "STARTTOFINISH",
|
||||
ArchivedICalendarRelationshipType::Starttostart => "STARTTOSTART",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarResourceType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarResourceType::Projector => "PROJECTOR",
|
||||
ArchivedICalendarResourceType::Room => "ROOM",
|
||||
ArchivedICalendarResourceType::RemoteConferenceAudio => "REMOTE-CONFERENCE-AUDIO",
|
||||
ArchivedICalendarResourceType::RemoteConferenceVideo => "REMOTE-CONFERENCE-VIDEO",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarScheduleAgentValue {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarScheduleAgentValue::Server => "SERVER",
|
||||
ArchivedICalendarScheduleAgentValue::Client => "CLIENT",
|
||||
ArchivedICalendarScheduleAgentValue::None => "NONE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarScheduleForceSendValue {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarScheduleForceSendValue::Request => "REQUEST",
|
||||
ArchivedICalendarScheduleForceSendValue::Reply => "REPLY",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarValueType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarValueType::Binary => "BINARY",
|
||||
ArchivedICalendarValueType::Boolean => "BOOLEAN",
|
||||
ArchivedICalendarValueType::CalAddress => "CAL-ADDRESS",
|
||||
ArchivedICalendarValueType::Date => "DATE",
|
||||
ArchivedICalendarValueType::DateTime => "DATE-TIME",
|
||||
ArchivedICalendarValueType::Duration => "DURATION",
|
||||
ArchivedICalendarValueType::Float => "FLOAT",
|
||||
ArchivedICalendarValueType::Integer => "INTEGER",
|
||||
ArchivedICalendarValueType::Period => "PERIOD",
|
||||
ArchivedICalendarValueType::Recur => "RECUR",
|
||||
ArchivedICalendarValueType::Text => "TEXT",
|
||||
ArchivedICalendarValueType::Time => "TIME",
|
||||
ArchivedICalendarValueType::Unknown => "UNKNOWN",
|
||||
ArchivedICalendarValueType::Uri => "URI",
|
||||
ArchivedICalendarValueType::UtcOffset => "UTC-OFFSET",
|
||||
ArchivedICalendarValueType::XmlReference => "XML-REFERENCE",
|
||||
ArchivedICalendarValueType::Uid => "UID",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarTransparency {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarTransparency::Opaque => "OPAQUE",
|
||||
ArchivedICalendarTransparency::Transparent => "TRANSPARENT",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedLinkRelation {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedLinkRelation::About => "about",
|
||||
ArchivedLinkRelation::Acl => "acl",
|
||||
ArchivedLinkRelation::Alternate => "alternate",
|
||||
ArchivedLinkRelation::Amphtml => "amphtml",
|
||||
ArchivedLinkRelation::ApiCatalog => "api-catalog",
|
||||
ArchivedLinkRelation::Appendix => "appendix",
|
||||
ArchivedLinkRelation::AppleTouchIcon => "apple-touch-icon",
|
||||
ArchivedLinkRelation::AppleTouchStartupImage => "apple-touch-startup-image",
|
||||
ArchivedLinkRelation::Archives => "archives",
|
||||
ArchivedLinkRelation::Author => "author",
|
||||
ArchivedLinkRelation::BlockedBy => "blocked-by",
|
||||
ArchivedLinkRelation::Bookmark => "bookmark",
|
||||
ArchivedLinkRelation::C2paManifest => "c2pa-manifest",
|
||||
ArchivedLinkRelation::Canonical => "canonical",
|
||||
ArchivedLinkRelation::Chapter => "chapter",
|
||||
ArchivedLinkRelation::CiteAs => "cite-as",
|
||||
ArchivedLinkRelation::Collection => "collection",
|
||||
ArchivedLinkRelation::CompressionDictionary => "compression-dictionary",
|
||||
ArchivedLinkRelation::Contents => "contents",
|
||||
ArchivedLinkRelation::Convertedfrom => "convertedfrom",
|
||||
ArchivedLinkRelation::Copyright => "copyright",
|
||||
ArchivedLinkRelation::CreateForm => "create-form",
|
||||
ArchivedLinkRelation::Current => "current",
|
||||
ArchivedLinkRelation::Deprecation => "deprecation",
|
||||
ArchivedLinkRelation::Describedby => "describedby",
|
||||
ArchivedLinkRelation::Describes => "describes",
|
||||
ArchivedLinkRelation::Disclosure => "disclosure",
|
||||
ArchivedLinkRelation::DnsPrefetch => "dns-prefetch",
|
||||
ArchivedLinkRelation::Duplicate => "duplicate",
|
||||
ArchivedLinkRelation::Edit => "edit",
|
||||
ArchivedLinkRelation::EditForm => "edit-form",
|
||||
ArchivedLinkRelation::EditMedia => "edit-media",
|
||||
ArchivedLinkRelation::Enclosure => "enclosure",
|
||||
ArchivedLinkRelation::External => "external",
|
||||
ArchivedLinkRelation::First => "first",
|
||||
ArchivedLinkRelation::Geofeed => "geofeed",
|
||||
ArchivedLinkRelation::Glossary => "glossary",
|
||||
ArchivedLinkRelation::Help => "help",
|
||||
ArchivedLinkRelation::Hosts => "hosts",
|
||||
ArchivedLinkRelation::Hub => "hub",
|
||||
ArchivedLinkRelation::IceServer => "ice-server",
|
||||
ArchivedLinkRelation::Icon => "icon",
|
||||
ArchivedLinkRelation::Index => "index",
|
||||
ArchivedLinkRelation::Intervalafter => "intervalafter",
|
||||
ArchivedLinkRelation::Intervalbefore => "intervalbefore",
|
||||
ArchivedLinkRelation::Intervalcontains => "intervalcontains",
|
||||
ArchivedLinkRelation::Intervaldisjoint => "intervaldisjoint",
|
||||
ArchivedLinkRelation::Intervalduring => "intervalduring",
|
||||
ArchivedLinkRelation::Intervalequals => "intervalequals",
|
||||
ArchivedLinkRelation::Intervalfinishedby => "intervalfinishedby",
|
||||
ArchivedLinkRelation::Intervalfinishes => "intervalfinishes",
|
||||
ArchivedLinkRelation::Intervalin => "intervalin",
|
||||
ArchivedLinkRelation::Intervalmeets => "intervalmeets",
|
||||
ArchivedLinkRelation::Intervalmetby => "intervalmetby",
|
||||
ArchivedLinkRelation::Intervaloverlappedby => "intervaloverlappedby",
|
||||
ArchivedLinkRelation::Intervaloverlaps => "intervaloverlaps",
|
||||
ArchivedLinkRelation::Intervalstartedby => "intervalstartedby",
|
||||
ArchivedLinkRelation::Intervalstarts => "intervalstarts",
|
||||
ArchivedLinkRelation::Item => "item",
|
||||
ArchivedLinkRelation::Last => "last",
|
||||
ArchivedLinkRelation::LatestVersion => "latest-version",
|
||||
ArchivedLinkRelation::License => "license",
|
||||
ArchivedLinkRelation::Linkset => "linkset",
|
||||
ArchivedLinkRelation::Lrdd => "lrdd",
|
||||
ArchivedLinkRelation::Manifest => "manifest",
|
||||
ArchivedLinkRelation::MaskIcon => "mask-icon",
|
||||
ArchivedLinkRelation::Me => "me",
|
||||
ArchivedLinkRelation::MediaFeed => "media-feed",
|
||||
ArchivedLinkRelation::Memento => "memento",
|
||||
ArchivedLinkRelation::Micropub => "micropub",
|
||||
ArchivedLinkRelation::Modulepreload => "modulepreload",
|
||||
ArchivedLinkRelation::Monitor => "monitor",
|
||||
ArchivedLinkRelation::MonitorGroup => "monitor-group",
|
||||
ArchivedLinkRelation::Next => "next",
|
||||
ArchivedLinkRelation::NextArchive => "next-archive",
|
||||
ArchivedLinkRelation::Nofollow => "nofollow",
|
||||
ArchivedLinkRelation::Noopener => "noopener",
|
||||
ArchivedLinkRelation::Noreferrer => "noreferrer",
|
||||
ArchivedLinkRelation::Opener => "opener",
|
||||
ArchivedLinkRelation::Openid2LocalId => "openid2.local_id",
|
||||
ArchivedLinkRelation::Openid2Provider => "openid2.provider",
|
||||
ArchivedLinkRelation::Original => "original",
|
||||
ArchivedLinkRelation::P3pv1 => "p3pv1",
|
||||
ArchivedLinkRelation::Payment => "payment",
|
||||
ArchivedLinkRelation::Pingback => "pingback",
|
||||
ArchivedLinkRelation::Preconnect => "preconnect",
|
||||
ArchivedLinkRelation::PredecessorVersion => "predecessor-version",
|
||||
ArchivedLinkRelation::Prefetch => "prefetch",
|
||||
ArchivedLinkRelation::Preload => "preload",
|
||||
ArchivedLinkRelation::Prerender => "prerender",
|
||||
ArchivedLinkRelation::Prev => "prev",
|
||||
ArchivedLinkRelation::Preview => "preview",
|
||||
ArchivedLinkRelation::Previous => "previous",
|
||||
ArchivedLinkRelation::PrevArchive => "prev-archive",
|
||||
ArchivedLinkRelation::PrivacyPolicy => "privacy-policy",
|
||||
ArchivedLinkRelation::Profile => "profile",
|
||||
ArchivedLinkRelation::Publication => "publication",
|
||||
ArchivedLinkRelation::RdapActive => "rdap-active",
|
||||
ArchivedLinkRelation::RdapBottom => "rdap-bottom",
|
||||
ArchivedLinkRelation::RdapDown => "rdap-down",
|
||||
ArchivedLinkRelation::RdapTop => "rdap-top",
|
||||
ArchivedLinkRelation::RdapUp => "rdap-up",
|
||||
ArchivedLinkRelation::Related => "related",
|
||||
ArchivedLinkRelation::Restconf => "restconf",
|
||||
ArchivedLinkRelation::Replies => "replies",
|
||||
ArchivedLinkRelation::Ruleinput => "ruleinput",
|
||||
ArchivedLinkRelation::Search => "search",
|
||||
ArchivedLinkRelation::Section => "section",
|
||||
ArchivedLinkRelation::Self_ => "self",
|
||||
ArchivedLinkRelation::Service => "service",
|
||||
ArchivedLinkRelation::ServiceDesc => "service-desc",
|
||||
ArchivedLinkRelation::ServiceDoc => "service-doc",
|
||||
ArchivedLinkRelation::ServiceMeta => "service-meta",
|
||||
ArchivedLinkRelation::SipTrunkingCapability => "sip-trunking-capability",
|
||||
ArchivedLinkRelation::Sponsored => "sponsored",
|
||||
ArchivedLinkRelation::Start => "start",
|
||||
ArchivedLinkRelation::Status => "status",
|
||||
ArchivedLinkRelation::Stylesheet => "stylesheet",
|
||||
ArchivedLinkRelation::Subsection => "subsection",
|
||||
ArchivedLinkRelation::SuccessorVersion => "successor-version",
|
||||
ArchivedLinkRelation::Sunset => "sunset",
|
||||
ArchivedLinkRelation::Tag => "tag",
|
||||
ArchivedLinkRelation::TermsOfService => "terms-of-service",
|
||||
ArchivedLinkRelation::Timegate => "timegate",
|
||||
ArchivedLinkRelation::Timemap => "timemap",
|
||||
ArchivedLinkRelation::Type => "type",
|
||||
ArchivedLinkRelation::Ugc => "ugc",
|
||||
ArchivedLinkRelation::Up => "up",
|
||||
ArchivedLinkRelation::VersionHistory => "version-history",
|
||||
ArchivedLinkRelation::Via => "via",
|
||||
ArchivedLinkRelation::Webmention => "webmention",
|
||||
ArchivedLinkRelation::WorkingCopy => "working-copy",
|
||||
ArchivedLinkRelation::WorkingCopyOf => "working-copy-of",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarRelated {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ArchivedICalendarRelated::Start => "START",
|
||||
ArchivedICalendarRelated::End => "END",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum ArchivedValueType {
|
||||
Ical(ArchivedICalendarValueType),
|
||||
CalendarScale,
|
||||
Method,
|
||||
Classification,
|
||||
Status,
|
||||
Transparency,
|
||||
Action,
|
||||
BusyType,
|
||||
ParticipantType,
|
||||
ResourceType,
|
||||
Proximity,
|
||||
}
|
||||
impl ArchivedValueType {
|
||||
pub fn unwrap_ical(self) -> ArchivedICalendarValueType {
|
||||
match self {
|
||||
ArchivedValueType::Ical(v) => v,
|
||||
_ => ArchivedICalendarValueType::Text,
|
||||
}
|
||||
}
|
||||
}
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
use crate::common::{ArchivedPartialDateTime, timezone::Tz};
|
||||
use chrono::DateTime;
|
||||
|
||||
impl ArchivedICalendar {
|
||||
pub fn uids(&self) -> impl Iterator<Item = &str> {
|
||||
self.components
|
||||
.iter()
|
||||
.filter_map(|component| component.uid())
|
||||
}
|
||||
|
||||
pub fn size(&self) -> usize {
|
||||
self.components
|
||||
.iter()
|
||||
.map(|component| component.size())
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarComponent {
|
||||
pub fn uid(&self) -> Option<&str> {
|
||||
self.property(&ICalendarProperty::Uid)
|
||||
.and_then(|e| e.values.first())
|
||||
.and_then(|v| v.as_text())
|
||||
}
|
||||
|
||||
pub fn property(&self, prop: &ICalendarProperty) -> Option<&ArchivedICalendarEntry> {
|
||||
self.entries.iter().find(|entry| &entry.name == prop)
|
||||
}
|
||||
|
||||
pub fn properties<'x, 'y: 'x>(
|
||||
&'x self,
|
||||
prop: &'y ICalendarProperty,
|
||||
) -> impl Iterator<Item = &'x ArchivedICalendarEntry> + 'x {
|
||||
self.entries.iter().filter(move |entry| &entry.name == prop)
|
||||
}
|
||||
|
||||
pub fn size(&self) -> usize {
|
||||
self.entries.iter().map(|entry| entry.size()).sum()
|
||||
}
|
||||
|
||||
pub fn is_recurrent(&self) -> bool {
|
||||
self.entries.iter().any(|entry| {
|
||||
matches!(
|
||||
entry.name,
|
||||
ArchivedICalendarProperty::Rrule | ArchivedICalendarProperty::Rdate
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_recurrence_override(&self) -> bool {
|
||||
self.entries
|
||||
.iter()
|
||||
.any(|entry| matches!(entry.name, ArchivedICalendarProperty::RecurrenceId))
|
||||
}
|
||||
|
||||
pub fn is_recurrent_or_override(&self) -> bool {
|
||||
self.entries.iter().any(|entry| {
|
||||
matches!(
|
||||
entry.name,
|
||||
ArchivedICalendarProperty::Rrule
|
||||
| ArchivedICalendarProperty::Rdate
|
||||
| ArchivedICalendarProperty::RecurrenceId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Option<&ArchivedICalendarStatus> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find_map(|entry| match (&entry.name, entry.values.first()) {
|
||||
(
|
||||
ArchivedICalendarProperty::Status,
|
||||
Some(ArchivedICalendarValue::Status(status)),
|
||||
) => Some(status),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn transparency(&self) -> Option<&ArchivedICalendarTransparency> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find_map(|entry| match (&entry.name, entry.values.first()) {
|
||||
(
|
||||
ArchivedICalendarProperty::Transp,
|
||||
Some(ArchivedICalendarValue::Transparency(trans)),
|
||||
) => Some(trans),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarValue {
|
||||
pub fn size(&self) -> usize {
|
||||
match self {
|
||||
ArchivedICalendarValue::Binary(value) => value.len(),
|
||||
ArchivedICalendarValue::Text(value) => value.len(),
|
||||
ArchivedICalendarValue::PartialDateTime(_) => std::mem::size_of::<PartialDateTime>(),
|
||||
ArchivedICalendarValue::RecurrenceRule(_) => {
|
||||
std::mem::size_of::<ICalendarRecurrenceRule>()
|
||||
}
|
||||
_ => std::mem::size_of::<ICalendarValue>(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
ArchivedICalendarValue::Text(s) => Some(s.as_str()),
|
||||
ArchivedICalendarValue::Uri(v) => v.as_str(),
|
||||
ArchivedICalendarValue::CalendarScale(v) => Some(v.as_str()),
|
||||
ArchivedICalendarValue::Method(v) => Some(v.as_str()),
|
||||
ArchivedICalendarValue::Classification(v) => Some(v.as_str()),
|
||||
ArchivedICalendarValue::Status(v) => Some(v.as_str()),
|
||||
ArchivedICalendarValue::Transparency(v) => Some(v.as_str()),
|
||||
ArchivedICalendarValue::Action(v) => Some(v.as_str()),
|
||||
ArchivedICalendarValue::BusyType(v) => Some(v.as_str()),
|
||||
ArchivedICalendarValue::ParticipantType(v) => Some(v.as_str()),
|
||||
ArchivedICalendarValue::ResourceType(v) => Some(v.as_str()),
|
||||
ArchivedICalendarValue::Proximity(v) => Some(v.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_integer(&self) -> Option<i64> {
|
||||
match self {
|
||||
ArchivedICalendarValue::Integer(i) => Some(i.to_native()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_float(&self) -> Option<f64> {
|
||||
match self {
|
||||
ArchivedICalendarValue::Float(f) => Some(f.to_native()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_boolean(&self) -> Option<bool> {
|
||||
match self {
|
||||
ArchivedICalendarValue::Boolean(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_partial_date_time(&self) -> Option<&ArchivedPartialDateTime> {
|
||||
match self {
|
||||
ArchivedICalendarValue::PartialDateTime(dt) => Some(dt),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_binary(&self) -> Option<&[u8]> {
|
||||
match self {
|
||||
ArchivedICalendarValue::Binary(d) => Some(d.as_slice()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarParameterName {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ArchivedICalendarParameterName::Altrep => "ALTREP",
|
||||
ArchivedICalendarParameterName::Cn => "CN",
|
||||
ArchivedICalendarParameterName::Cutype => "CUTYPE",
|
||||
ArchivedICalendarParameterName::DelegatedFrom => "DELEGATED-FROM",
|
||||
ArchivedICalendarParameterName::DelegatedTo => "DELEGATED-TO",
|
||||
ArchivedICalendarParameterName::Dir => "DIR",
|
||||
ArchivedICalendarParameterName::Fmttype => "FMTTYPE",
|
||||
ArchivedICalendarParameterName::Fbtype => "FBTYPE",
|
||||
ArchivedICalendarParameterName::Language => "LANGUAGE",
|
||||
ArchivedICalendarParameterName::Member => "MEMBER",
|
||||
ArchivedICalendarParameterName::Partstat => "PARTSTAT",
|
||||
ArchivedICalendarParameterName::Range => "RANGE",
|
||||
ArchivedICalendarParameterName::Related => "RELATED",
|
||||
ArchivedICalendarParameterName::Reltype => "RELTYPE",
|
||||
ArchivedICalendarParameterName::Role => "ROLE",
|
||||
ArchivedICalendarParameterName::Rsvp => "RSVP",
|
||||
ArchivedICalendarParameterName::ScheduleAgent => "SCHEDULE-AGENT",
|
||||
ArchivedICalendarParameterName::ScheduleForceSend => "SCHEDULE-FORCE-SEND",
|
||||
ArchivedICalendarParameterName::ScheduleStatus => "SCHEDULE-STATUS",
|
||||
ArchivedICalendarParameterName::SentBy => "SENT-BY",
|
||||
ArchivedICalendarParameterName::Tzid => "TZID",
|
||||
ArchivedICalendarParameterName::Value => "VALUE",
|
||||
ArchivedICalendarParameterName::Display => "DISPLAY",
|
||||
ArchivedICalendarParameterName::Email => "EMAIL",
|
||||
ArchivedICalendarParameterName::Feature => "FEATURE",
|
||||
ArchivedICalendarParameterName::Label => "LABEL",
|
||||
ArchivedICalendarParameterName::Size => "SIZE",
|
||||
ArchivedICalendarParameterName::Filename => "FILENAME",
|
||||
ArchivedICalendarParameterName::ManagedId => "MANAGED-ID",
|
||||
ArchivedICalendarParameterName::Order => "ORDER",
|
||||
ArchivedICalendarParameterName::Schema => "SCHEMA",
|
||||
ArchivedICalendarParameterName::Derived => "DERIVED",
|
||||
ArchivedICalendarParameterName::Gap => "GAP",
|
||||
ArchivedICalendarParameterName::Linkrel => "LINKREL",
|
||||
ArchivedICalendarParameterName::Jsptr => "JSPTR",
|
||||
ArchivedICalendarParameterName::Jsid => "JSID",
|
||||
ArchivedICalendarParameterName::Other(name) => name.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarEntry {
|
||||
#[inline]
|
||||
pub fn parameters(
|
||||
&self,
|
||||
prop: &ICalendarParameterName,
|
||||
) -> impl Iterator<Item = &ArchivedICalendarParameterValue> {
|
||||
self.params.iter().filter_map(move |param| {
|
||||
if ¶m.name == prop {
|
||||
Some(¶m.value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn size(&self) -> usize {
|
||||
self.values.iter().map(|value| value.size()).sum::<usize>()
|
||||
+ self
|
||||
.params
|
||||
.iter()
|
||||
.map(|param| std::mem::size_of::<ICalendarParameterName>() + param.value.size())
|
||||
.sum::<usize>()
|
||||
+ self.name.as_str().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarParameterValue {
|
||||
pub fn as_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
ArchivedICalendarParameterValue::Text(v) => Some(v.as_str()),
|
||||
ArchivedICalendarParameterValue::Bool(v) => Some(if *v { "TRUE" } else { "FALSE" }),
|
||||
ArchivedICalendarParameterValue::Uri(uri) => uri.as_str(),
|
||||
ArchivedICalendarParameterValue::Cutype(v) => Some(v.as_str()),
|
||||
ArchivedICalendarParameterValue::Fbtype(v) => Some(v.as_str()),
|
||||
ArchivedICalendarParameterValue::Partstat(v) => Some(v.as_str()),
|
||||
ArchivedICalendarParameterValue::Related(v) => Some(v.as_str()),
|
||||
ArchivedICalendarParameterValue::Reltype(v) => Some(v.as_str()),
|
||||
ArchivedICalendarParameterValue::Role(v) => Some(v.as_str()),
|
||||
ArchivedICalendarParameterValue::ScheduleAgent(v) => Some(v.as_str()),
|
||||
ArchivedICalendarParameterValue::ScheduleForceSend(v) => Some(v.as_str()),
|
||||
ArchivedICalendarParameterValue::Value(v) => Some(v.as_str()),
|
||||
ArchivedICalendarParameterValue::Display(v) => Some(v.as_str()),
|
||||
ArchivedICalendarParameterValue::Feature(v) => Some(v.as_str()),
|
||||
ArchivedICalendarParameterValue::Linkrel(v) => Some(v.as_str()),
|
||||
ArchivedICalendarParameterValue::Duration(_)
|
||||
| ArchivedICalendarParameterValue::Integer(_)
|
||||
| ArchivedICalendarParameterValue::Null => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(&self) -> usize {
|
||||
match self {
|
||||
ArchivedICalendarParameterValue::Text(v) => v.len(),
|
||||
ArchivedICalendarParameterValue::Uri(v) => v.size(),
|
||||
_ => std::mem::size_of::<ICalendarParameterValue>(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedUri {
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
ArchivedUri::Data(data) => data.content_type.as_deref(),
|
||||
ArchivedUri::Location(loc) => loc.as_str().into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(&self) -> usize {
|
||||
match self {
|
||||
ArchivedUri::Data(data) => {
|
||||
data.data.len() + data.content_type.as_ref().map(|s| s.len()).unwrap_or(0)
|
||||
}
|
||||
ArchivedUri::Location(loc) => loc.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarPeriod {
|
||||
pub fn time_range(&self, tz: Tz) -> Option<(DateTime<Tz>, DateTime<Tz>)> {
|
||||
match self {
|
||||
ArchivedICalendarPeriod::Range { start, end } => {
|
||||
if let (Some(start), Some(end)) = (
|
||||
start
|
||||
.to_date_time()
|
||||
.and_then(|start| start.to_date_time_with_tz(tz)),
|
||||
end.to_date_time()
|
||||
.and_then(|end| end.to_date_time_with_tz(tz)),
|
||||
) {
|
||||
Some((start, end))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
ArchivedICalendarPeriod::Duration { start, duration } => start
|
||||
.to_date_time()
|
||||
.and_then(|start| start.to_date_time_with_tz(tz))
|
||||
.zip(duration.to_time_delta())
|
||||
.and_then(|(start, duration)| {
|
||||
start.checked_add_signed(duration).map(|end| (start, end))
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedPartialDateTime {
|
||||
pub fn to_date_time_with_tz(&self, tz: Tz) -> Option<DateTime<Tz>> {
|
||||
self.to_date_time()
|
||||
.and_then(|dt| dt.to_date_time_with_tz(tz))
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn has_date(&self) -> bool {
|
||||
self.year.is_some() && self.month.is_some() && self.day.is_some()
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn has_time(&self) -> bool {
|
||||
self.hour.is_some() && self.minute.is_some()
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn has_zone(&self) -> bool {
|
||||
self.tz_hour.is_some()
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn has_date_and_time(&self) -> bool {
|
||||
self.has_date() && self.has_time()
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarDuration {
|
||||
pub fn to_time_delta(&self) -> Option<chrono::TimeDelta> {
|
||||
chrono::TimeDelta::new(self.as_seconds(), 0)
|
||||
}
|
||||
|
||||
pub fn as_seconds(&self) -> i64 {
|
||||
let secs = self.seconds.to_native() as i64
|
||||
+ self.minutes.to_native() as i64 * 60
|
||||
+ self.hours.to_native() as i64 * 3600
|
||||
+ self.days.to_native() as i64 * 86400
|
||||
+ self.weeks.to_native() as i64 * 604800;
|
||||
|
||||
if self.neg { -secs } else { secs }
|
||||
}
|
||||
|
||||
pub fn to_native(&self) -> ICalendarDuration {
|
||||
ICalendarDuration {
|
||||
neg: self.neg,
|
||||
weeks: self.weeks.to_native(),
|
||||
days: self.days.to_native(),
|
||||
hours: self.hours.to_native(),
|
||||
minutes: self.minutes.to_native(),
|
||||
seconds: self.seconds.to_native(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarComponentType {
|
||||
pub fn has_time_ranges(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ArchivedICalendarComponentType::VEvent
|
||||
| ArchivedICalendarComponentType::VTodo
|
||||
| ArchivedICalendarComponentType::VJournal
|
||||
| ArchivedICalendarComponentType::VFreebusy
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_scheduling_object(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ArchivedICalendarComponentType::VEvent
|
||||
| ArchivedICalendarComponentType::VTodo
|
||||
| ArchivedICalendarComponentType::VJournal
|
||||
| ArchivedICalendarComponentType::VFreebusy
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_event(&self) -> bool {
|
||||
matches!(self, ArchivedICalendarComponentType::VEvent)
|
||||
}
|
||||
|
||||
pub fn is_todo(&self) -> bool {
|
||||
matches!(self, ArchivedICalendarComponentType::VTodo)
|
||||
}
|
||||
|
||||
pub fn is_event_or_todo(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ArchivedICalendarComponentType::VEvent | ArchivedICalendarComponentType::VTodo
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_journal(&self) -> bool {
|
||||
matches!(self, ArchivedICalendarComponentType::VJournal)
|
||||
}
|
||||
|
||||
pub fn is_freebusy(&self) -> bool {
|
||||
matches!(self, ArchivedICalendarComponentType::VFreebusy)
|
||||
}
|
||||
|
||||
pub fn is_location(&self) -> bool {
|
||||
matches!(self, ArchivedICalendarComponentType::VLocation)
|
||||
}
|
||||
|
||||
pub fn is_alarm(&self) -> bool {
|
||||
matches!(self, ArchivedICalendarComponentType::VAlarm)
|
||||
}
|
||||
|
||||
pub fn is_participant(&self) -> bool {
|
||||
matches!(self, ArchivedICalendarComponentType::Participant)
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarMonth {
|
||||
pub fn is_leap(&self) -> bool {
|
||||
self.0 < 0
|
||||
}
|
||||
|
||||
pub fn month(&self) -> u8 {
|
||||
self.0.unsigned_abs()
|
||||
}
|
||||
}
|
||||
+519
@@ -0,0 +1,519 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
common::{
|
||||
ArchivedPartialDateTime, CalendarScale,
|
||||
writer::{FoldingWriter, LineWriter, write_bytes, write_param_value, write_text},
|
||||
},
|
||||
icalendar::{
|
||||
ValueSeparator,
|
||||
writer::{write_component_begin, write_component_end},
|
||||
},
|
||||
};
|
||||
use std::{
|
||||
fmt::{Display, Write},
|
||||
slice::Iter,
|
||||
};
|
||||
|
||||
impl ArchivedICalendar {
|
||||
pub fn write_to(&self, out: &mut impl Write) -> std::fmt::Result {
|
||||
let _v = [0.into()];
|
||||
let mut component_iter: Iter<'_, rkyv::primitive::ArchivedU32> = _v.iter();
|
||||
let mut component_stack = Vec::with_capacity(4);
|
||||
|
||||
loop {
|
||||
if let Some(component_id) = component_iter.next() {
|
||||
let component = self
|
||||
.components
|
||||
.get(component_id.to_native() as usize)
|
||||
.unwrap();
|
||||
write_component_begin(out, component.component_type.as_str())?;
|
||||
|
||||
for entry in component.entries.iter() {
|
||||
if !matches!(
|
||||
entry.name,
|
||||
ArchivedICalendarProperty::Begin | ArchivedICalendarProperty::End
|
||||
) {
|
||||
entry.write_to(out, true)?;
|
||||
}
|
||||
}
|
||||
|
||||
if !component.component_ids.is_empty() {
|
||||
component_stack.push((component, component_iter));
|
||||
component_iter = component.component_ids.iter();
|
||||
} else {
|
||||
write_component_end(out, component.component_type.as_str())?;
|
||||
}
|
||||
} else if let Some((component, iter)) = component_stack.pop() {
|
||||
write_component_end(out, component.component_type.as_str())?;
|
||||
component_iter = iter;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedICalendarEntry {
|
||||
pub fn write_to(&self, out: &mut impl Write, with_value: bool) -> std::fmt::Result {
|
||||
let mut folded = FoldingWriter::new(out);
|
||||
let out = &mut folded;
|
||||
|
||||
out.write_atomic(self.name.as_str())?;
|
||||
|
||||
if matches!(
|
||||
self.values.first().as_ref(),
|
||||
Some(ArchivedICalendarValue::Binary(_))
|
||||
) {
|
||||
out.write_atomic(";ENCODING=BASE64")?;
|
||||
}
|
||||
|
||||
let mut types = None;
|
||||
let mut last_param: Option<&ArchivedICalendarParameterName> = None;
|
||||
|
||||
for param in self.params.iter() {
|
||||
if last_param.is_some_and(|last_param| last_param == ¶m.name) {
|
||||
out.write_atomic(",")?;
|
||||
} else {
|
||||
out.write_atomic(";")?;
|
||||
out.write_atomic(param.name.as_str())?;
|
||||
if !matches!(param.value, ArchivedICalendarParameterValue::Null) {
|
||||
out.write_atomic("=")?;
|
||||
}
|
||||
last_param = Some(¶m.name);
|
||||
}
|
||||
|
||||
match ¶m.value {
|
||||
ArchivedICalendarParameterValue::Text(v) => {
|
||||
write_param_value(out, v)?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Integer(i) => {
|
||||
write!(out, "{i}")?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Bool(v) => {
|
||||
let v = if !matches!(param.name, ArchivedICalendarParameterName::Range) {
|
||||
if *v { "TRUE" } else { "FALSE" }
|
||||
} else {
|
||||
"THISANDFUTURE"
|
||||
};
|
||||
out.write_atomic(v)?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Uri(uri) => {
|
||||
out.write_atomic("\"")?;
|
||||
write_uri(out, uri, false)?;
|
||||
out.write_atomic("\"")?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Cutype(v) => {
|
||||
write_param_value(out, v.as_str())?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Fbtype(v) => {
|
||||
write_param_value(out, v.as_str())?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Partstat(v) => {
|
||||
write_param_value(out, v.as_str())?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Related(v) => {
|
||||
write_param_value(out, v.as_str())?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Reltype(v) => {
|
||||
write_param_value(out, v.as_str())?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Role(v) => {
|
||||
write_param_value(out, v.as_str())?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::ScheduleAgent(v) => {
|
||||
write_param_value(out, v.as_str())?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::ScheduleForceSend(v) => {
|
||||
write_param_value(out, v.as_str())?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Value(v) => {
|
||||
types = Some(v);
|
||||
write_param_value(out, v.as_str())?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Display(v) => {
|
||||
write_param_value(out, v.as_str())?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Feature(v) => {
|
||||
write_param_value(out, v.as_str())?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Duration(v) => {
|
||||
write!(out, "{v}")?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Linkrel(v) => {
|
||||
write_param_value(out, v.as_str())?;
|
||||
}
|
||||
ArchivedICalendarParameterValue::Null => {
|
||||
last_param = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.write_atomic(":")?;
|
||||
|
||||
if with_value {
|
||||
let (default_type, separator) = self.name.default_types();
|
||||
let separator = if !matches!(separator, ValueSeparator::Comma) {
|
||||
";"
|
||||
} else {
|
||||
","
|
||||
};
|
||||
let default_type = default_type.unwrap_ical();
|
||||
|
||||
for (pos, value) in self.values.iter().enumerate() {
|
||||
if pos > 0 {
|
||||
out.write_atomic(separator)?;
|
||||
}
|
||||
|
||||
let text = match value {
|
||||
ArchivedICalendarValue::Binary(v) => {
|
||||
write_bytes(out, v)?;
|
||||
continue;
|
||||
}
|
||||
ArchivedICalendarValue::Boolean(v) => {
|
||||
out.write_atomic(if *v { "TRUE" } else { "FALSE" })?;
|
||||
continue;
|
||||
}
|
||||
ArchivedICalendarValue::Uri(v) => {
|
||||
write_uri(out, v, true)?;
|
||||
continue;
|
||||
}
|
||||
ArchivedICalendarValue::PartialDateTime(v) => {
|
||||
v.format_as_ical(out, types.unwrap_or(&default_type))?;
|
||||
continue;
|
||||
}
|
||||
ArchivedICalendarValue::Duration(v) => {
|
||||
write!(out, "{}", v)?;
|
||||
continue;
|
||||
}
|
||||
ArchivedICalendarValue::RecurrenceRule(v) => {
|
||||
write!(out, "{}", v)?;
|
||||
continue;
|
||||
}
|
||||
ArchivedICalendarValue::Period(v) => {
|
||||
write!(out, "{}", v)?;
|
||||
continue;
|
||||
}
|
||||
ArchivedICalendarValue::Float(v) => {
|
||||
write!(out, "{v}")?;
|
||||
continue;
|
||||
}
|
||||
ArchivedICalendarValue::Integer(v) => {
|
||||
write!(out, "{v}")?;
|
||||
continue;
|
||||
}
|
||||
ArchivedICalendarValue::Text(v) => {
|
||||
let escape = !matches!(
|
||||
types.unwrap_or(&default_type),
|
||||
ArchivedICalendarValueType::Recur
|
||||
);
|
||||
write_text(out, v, escape, escape)?;
|
||||
continue;
|
||||
}
|
||||
ArchivedICalendarValue::CalendarScale(v) => v.as_str(),
|
||||
ArchivedICalendarValue::Method(v) => v.as_str(),
|
||||
ArchivedICalendarValue::Classification(v) => v.as_str(),
|
||||
ArchivedICalendarValue::Status(v) => v.as_str(),
|
||||
ArchivedICalendarValue::Transparency(v) => v.as_str(),
|
||||
ArchivedICalendarValue::Action(v) => v.as_str(),
|
||||
ArchivedICalendarValue::BusyType(v) => v.as_str(),
|
||||
ArchivedICalendarValue::ParticipantType(v) => v.as_str(),
|
||||
ArchivedICalendarValue::ResourceType(v) => v.as_str(),
|
||||
ArchivedICalendarValue::Proximity(v) => v.as_str(),
|
||||
};
|
||||
|
||||
out.write_atomic(text)?;
|
||||
}
|
||||
}
|
||||
out.end_line()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_uri<W: Write>(
|
||||
out: &mut FoldingWriter<'_, W>,
|
||||
value: &ArchivedUri,
|
||||
escape: bool,
|
||||
) -> std::fmt::Result {
|
||||
match value {
|
||||
ArchivedUri::Data(v) => {
|
||||
let media_type = v.content_type.as_deref().unwrap_or_default();
|
||||
out.write_str("data:")?;
|
||||
out.write_str(media_type)?;
|
||||
out.write_str(";")?;
|
||||
if escape {
|
||||
out.write_atomic("base64\\,")?;
|
||||
} else {
|
||||
out.write_atomic("base64,")?;
|
||||
}
|
||||
write_bytes(out, &v.data)
|
||||
}
|
||||
ArchivedUri::Location(v) => write_text(out, v, escape, escape),
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ArchivedICalendarRecurrenceRule {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "FREQ={}", self.freq.as_str())?;
|
||||
if let Some(until) = self.until.as_ref() {
|
||||
write!(f, ";UNTIL=")?;
|
||||
until.format_as_ical(
|
||||
f,
|
||||
if until.has_date_and_time() {
|
||||
&ArchivedICalendarValueType::DateTime
|
||||
} else {
|
||||
&ArchivedICalendarValueType::Date
|
||||
},
|
||||
)?;
|
||||
}
|
||||
if let Some(count) = self.count.as_ref().filter(|c| **c > 0) {
|
||||
write!(f, ";COUNT={}", count)?;
|
||||
}
|
||||
if let Some(interval) = self.interval.as_ref() {
|
||||
write!(f, ";INTERVAL={}", interval)?;
|
||||
}
|
||||
if !self.bysecond.is_empty() {
|
||||
write!(f, ";BYSECOND=")?;
|
||||
for (pos, item) in self.bysecond.iter().enumerate() {
|
||||
if pos > 0 {
|
||||
write!(f, ",")?;
|
||||
}
|
||||
write!(f, "{}", item)?;
|
||||
}
|
||||
}
|
||||
if !self.byminute.is_empty() {
|
||||
write!(f, ";BYMINUTE=")?;
|
||||
for (pos, item) in self.byminute.iter().enumerate() {
|
||||
if pos > 0 {
|
||||
write!(f, ",")?;
|
||||
}
|
||||
write!(f, "{}", item)?;
|
||||
}
|
||||
}
|
||||
if !self.byhour.is_empty() {
|
||||
write!(f, ";BYHOUR=")?;
|
||||
for (pos, item) in self.byhour.iter().enumerate() {
|
||||
if pos > 0 {
|
||||
write!(f, ",")?;
|
||||
}
|
||||
write!(f, "{}", item)?;
|
||||
}
|
||||
}
|
||||
if !self.byday.is_empty() {
|
||||
write!(f, ";BYDAY=")?;
|
||||
for (pos, item) in self.byday.iter().enumerate() {
|
||||
if pos > 0 {
|
||||
write!(f, ",")?;
|
||||
}
|
||||
write!(f, "{}", item)?;
|
||||
}
|
||||
}
|
||||
if !self.bymonthday.is_empty() {
|
||||
write!(f, ";BYMONTHDAY=")?;
|
||||
for (pos, item) in self.bymonthday.iter().enumerate() {
|
||||
if pos > 0 {
|
||||
write!(f, ",")?;
|
||||
}
|
||||
write!(f, "{}", item)?;
|
||||
}
|
||||
}
|
||||
if !self.byyearday.is_empty() {
|
||||
write!(f, ";BYYEARDAY=")?;
|
||||
for (pos, item) in self.byyearday.iter().enumerate() {
|
||||
if pos > 0 {
|
||||
write!(f, ",")?;
|
||||
}
|
||||
write!(f, "{}", item)?;
|
||||
}
|
||||
}
|
||||
if !self.byweekno.is_empty() {
|
||||
write!(f, ";BYWEEKNO=")?;
|
||||
for (pos, item) in self.byweekno.iter().enumerate() {
|
||||
if pos > 0 {
|
||||
write!(f, ",")?;
|
||||
}
|
||||
write!(f, "{}", item)?;
|
||||
}
|
||||
}
|
||||
if !self.bymonth.is_empty() {
|
||||
write!(f, ";BYMONTH=")?;
|
||||
for (pos, item) in self.bymonth.iter().enumerate() {
|
||||
if pos > 0 {
|
||||
write!(f, ",")?;
|
||||
}
|
||||
write!(f, "{}", item)?;
|
||||
}
|
||||
}
|
||||
if !self.bysetpos.is_empty() {
|
||||
write!(f, ";BYSETPOS=")?;
|
||||
for (pos, item) in self.bysetpos.iter().enumerate() {
|
||||
if pos > 0 {
|
||||
write!(f, ",")?;
|
||||
}
|
||||
write!(f, "{}", item)?;
|
||||
}
|
||||
}
|
||||
if let Some(wkst) = self.wkst.as_ref() {
|
||||
write!(f, ";WKST={}", wkst.as_str())?;
|
||||
}
|
||||
if let Some(rscale) = self.rscale.as_ref() {
|
||||
write!(f, ";RSCALE={}", rscale.as_str())?;
|
||||
} else if self.skip.is_some() {
|
||||
write!(f, ";RSCALE={}", CalendarScale::Gregorian.as_str())?;
|
||||
}
|
||||
if let Some(skip) = self.skip.as_ref() {
|
||||
write!(f, ";SKIP={}", skip.as_str())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ArchivedICalendarDay {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(ordwk) = self.ordwk.as_ref() {
|
||||
write!(f, "{}", ordwk)?;
|
||||
}
|
||||
write!(f, "{}", self.weekday.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ArchivedICalendarMonth {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if !self.is_leap() {
|
||||
write!(f, "{}", self.month())
|
||||
} else {
|
||||
write!(f, "{}L", self.month())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ArchivedICalendarPeriod {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ArchivedICalendarPeriod::Range { start, end } => {
|
||||
start.format_as_ical(f, &ArchivedICalendarValueType::DateTime)?;
|
||||
write!(f, "/")?;
|
||||
end.format_as_ical(f, &ArchivedICalendarValueType::DateTime)
|
||||
}
|
||||
ArchivedICalendarPeriod::Duration { start, duration } => {
|
||||
start.format_as_ical(f, &ArchivedICalendarValueType::DateTime)?;
|
||||
write!(f, "/{}", duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ArchivedICalendarDuration {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if self.neg {
|
||||
write!(f, "-")?;
|
||||
}
|
||||
write!(f, "P")?;
|
||||
if self.weeks == 0
|
||||
&& self.days == 0
|
||||
&& self.hours == 0
|
||||
&& self.minutes == 0
|
||||
&& self.seconds == 0
|
||||
{
|
||||
return write!(f, "T0S");
|
||||
}
|
||||
if self.weeks != 0 {
|
||||
write!(f, "{}W", self.weeks)?;
|
||||
}
|
||||
if self.days != 0 {
|
||||
write!(f, "{}D", self.days)?;
|
||||
}
|
||||
if self.hours != 0 || self.minutes != 0 || self.seconds != 0 {
|
||||
write!(f, "T")?;
|
||||
if self.hours != 0 {
|
||||
write!(f, "{}H", self.hours)?;
|
||||
}
|
||||
if self.minutes != 0 {
|
||||
write!(f, "{}M", self.minutes)?;
|
||||
}
|
||||
if self.seconds != 0 {
|
||||
write!(f, "{}S", self.seconds)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedPartialDateTime {
|
||||
pub fn format_as_ical(
|
||||
&self,
|
||||
out: &mut impl Write,
|
||||
fmt: &ArchivedICalendarValueType,
|
||||
) -> std::fmt::Result {
|
||||
if matches!(
|
||||
fmt,
|
||||
ArchivedICalendarValueType::Date | ArchivedICalendarValueType::DateTime
|
||||
) {
|
||||
write!(
|
||||
out,
|
||||
"{:04}{:02}{:02}",
|
||||
self.year
|
||||
.as_ref()
|
||||
.map(|n| n.to_native())
|
||||
.unwrap_or_default(),
|
||||
self.month.as_ref().copied().unwrap_or_default(),
|
||||
self.day.as_ref().copied().unwrap_or_default(),
|
||||
)?;
|
||||
}
|
||||
|
||||
if matches!(fmt, ArchivedICalendarValueType::DateTime) {
|
||||
write!(out, "T")?;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
fmt,
|
||||
ArchivedICalendarValueType::DateTime | ArchivedICalendarValueType::Time
|
||||
) {
|
||||
write!(
|
||||
out,
|
||||
"{:02}{:02}{:02}",
|
||||
self.hour.as_ref().copied().unwrap_or_default(),
|
||||
self.minute.as_ref().copied().unwrap_or_default(),
|
||||
self.second.as_ref().copied().unwrap_or_default(),
|
||||
)?;
|
||||
|
||||
if matches!(
|
||||
(self.tz_hour.as_ref(), self.tz_minute.as_ref()),
|
||||
(Some(0), Some(0))
|
||||
) {
|
||||
write!(out, "Z")?;
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(fmt, ArchivedICalendarValueType::UtcOffset) {
|
||||
if self.tz_minus {
|
||||
write!(out, "-")?;
|
||||
} else {
|
||||
write!(out, "+")?;
|
||||
}
|
||||
|
||||
write!(
|
||||
out,
|
||||
"{:02}{:02}",
|
||||
self.tz_hour.as_ref().copied().unwrap_or_default(),
|
||||
self.tz_minute.as_ref().copied().unwrap_or_default(),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ArchivedICalendar {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.write_to(f)
|
||||
}
|
||||
}
|
||||
+1221
File diff suppressed because it is too large.
Load diff
+1458
File diff suppressed because it is too large.
Load diff
+526
@@ -0,0 +1,526 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use super::{
|
||||
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarDuration, ICalendarEntry,
|
||||
ICalendarParameterName, ICalendarProperty, ICalendarRecurrenceRule, ICalendarStatus,
|
||||
ICalendarTransparency, ICalendarValue, Uri,
|
||||
};
|
||||
use crate::{
|
||||
common::{IanaString, IanaType, PartialDateTime},
|
||||
icalendar::{ICalendarParameterValue, ICalendarValueType},
|
||||
};
|
||||
|
||||
pub fn strip_mailto_scheme(value: &str) -> &str {
|
||||
value
|
||||
.split_once(':')
|
||||
.filter(|(scheme, _)| scheme.eq_ignore_ascii_case("mailto"))
|
||||
.map_or(value, |(_, address)| address)
|
||||
}
|
||||
|
||||
impl ICalendar {
|
||||
pub fn uids(&self) -> impl Iterator<Item = &str> {
|
||||
self.components
|
||||
.iter()
|
||||
.filter_map(|component| component.uid())
|
||||
}
|
||||
|
||||
pub fn size(&self) -> usize {
|
||||
self.components
|
||||
.iter()
|
||||
.map(|component| component.size())
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub fn component_by_id(&self, id: u32) -> Option<&ICalendarComponent> {
|
||||
self.components.get(id as usize)
|
||||
}
|
||||
|
||||
pub fn alarms_for_id(&self, id: u32) -> impl Iterator<Item = &ICalendarComponent> {
|
||||
self.component_by_id(id)
|
||||
.map_or(&[][..], |c| c.component_ids.as_slice())
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
self.component_by_id(*id)
|
||||
.filter(|c| c.component_type == ICalendarComponentType::VAlarm)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ICalendarComponent {
|
||||
pub fn uid(&self) -> Option<&str> {
|
||||
self.property(&ICalendarProperty::Uid)
|
||||
.and_then(|e| e.values.first())
|
||||
.and_then(|v| v.as_text())
|
||||
}
|
||||
|
||||
pub fn jsid(&self) -> Option<&str> {
|
||||
self.property(&ICalendarProperty::Jsid)
|
||||
.and_then(|e| e.values.first())
|
||||
.and_then(|v| v.as_text())
|
||||
}
|
||||
|
||||
pub fn property(&self, prop: &ICalendarProperty) -> Option<&ICalendarEntry> {
|
||||
self.entries.iter().find(|entry| &entry.name == prop)
|
||||
}
|
||||
|
||||
pub fn property_mut(&mut self, prop: &ICalendarProperty) -> Option<&mut ICalendarEntry> {
|
||||
self.entries.iter_mut().find(|entry| &entry.name == prop)
|
||||
}
|
||||
|
||||
pub fn has_property(&self, prop: &ICalendarProperty) -> bool {
|
||||
self.entries.iter().any(|entry| &entry.name == prop)
|
||||
}
|
||||
|
||||
pub fn properties<'x, 'y: 'x>(
|
||||
&'x self,
|
||||
prop: &'y ICalendarProperty,
|
||||
) -> impl Iterator<Item = &'x ICalendarEntry> + 'x {
|
||||
self.entries.iter().filter(move |entry| &entry.name == prop)
|
||||
}
|
||||
|
||||
pub fn properties_mut<'x, 'y: 'x>(
|
||||
&'x mut self,
|
||||
prop: &'y ICalendarProperty,
|
||||
) -> impl Iterator<Item = &'x mut ICalendarEntry> + 'x {
|
||||
self.entries
|
||||
.iter_mut()
|
||||
.filter(move |entry| &entry.name == prop)
|
||||
}
|
||||
|
||||
pub fn size(&self) -> usize {
|
||||
self.entries.iter().map(|entry| entry.size()).sum()
|
||||
}
|
||||
|
||||
pub fn is_recurrent(&self) -> bool {
|
||||
self.entries.iter().any(|entry| {
|
||||
matches!(
|
||||
entry.name,
|
||||
ICalendarProperty::Rrule | ICalendarProperty::Rdate
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_recurrence_override(&self) -> bool {
|
||||
self.entries
|
||||
.iter()
|
||||
.any(|entry| matches!(entry.name, ICalendarProperty::RecurrenceId))
|
||||
}
|
||||
|
||||
pub fn is_recurrent_or_override(&self) -> bool {
|
||||
self.entries.iter().any(|entry| {
|
||||
matches!(
|
||||
entry.name,
|
||||
ICalendarProperty::Rrule
|
||||
| ICalendarProperty::Rdate
|
||||
| ICalendarProperty::RecurrenceId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Option<&ICalendarStatus> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find_map(|entry| match (&entry.name, entry.values.first()) {
|
||||
(ICalendarProperty::Status, Some(ICalendarValue::Status(status))) => Some(status),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn transparency(&self) -> Option<&ICalendarTransparency> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find_map(|entry| match (&entry.name, entry.values.first()) {
|
||||
(ICalendarProperty::Transp, Some(ICalendarValue::Transparency(trans))) => {
|
||||
Some(trans)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ICalendarValue {
|
||||
pub fn size(&self) -> usize {
|
||||
match self {
|
||||
ICalendarValue::Binary(value) => value.len(),
|
||||
ICalendarValue::Text(value) => value.len(),
|
||||
ICalendarValue::PartialDateTime(_) => std::mem::size_of::<PartialDateTime>(),
|
||||
ICalendarValue::RecurrenceRule(_) => std::mem::size_of::<ICalendarRecurrenceRule>(),
|
||||
_ => std::mem::size_of::<ICalendarValue>(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
ICalendarValue::Text(s) => Some(s.as_str()),
|
||||
ICalendarValue::Uri(v) => v.as_str(),
|
||||
ICalendarValue::CalendarScale(v) => Some(v.as_str()),
|
||||
ICalendarValue::Method(v) => Some(v.as_str()),
|
||||
ICalendarValue::Classification(v) => Some(v.as_str()),
|
||||
ICalendarValue::Status(v) => Some(v.as_str()),
|
||||
ICalendarValue::Transparency(v) => Some(v.as_str()),
|
||||
ICalendarValue::Action(v) => Some(v.as_str()),
|
||||
ICalendarValue::BusyType(v) => Some(v.as_str()),
|
||||
ICalendarValue::ParticipantType(v) => Some(v.as_str()),
|
||||
ICalendarValue::ResourceType(v) => Some(v.as_str()),
|
||||
ICalendarValue::Proximity(v) => Some(v.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_text(self) -> Option<Cow<'static, str>> {
|
||||
match self {
|
||||
ICalendarValue::Text(s) => Some(Cow::Owned(s)),
|
||||
ICalendarValue::Uri(v) => Some(Cow::Owned(v.into_unwrapped_string())),
|
||||
ICalendarValue::CalendarScale(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarValue::Method(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarValue::Classification(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarValue::Status(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarValue::Transparency(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarValue::Action(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarValue::BusyType(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarValue::ParticipantType(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarValue::ResourceType(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarValue::Proximity(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarValue::Integer(i) => Some(Cow::Owned(i.to_string())),
|
||||
ICalendarValue::Float(f) => Some(Cow::Owned(f.to_string())),
|
||||
ICalendarValue::Boolean(b) => Some(Cow::Borrowed(if b { "TRUE" } else { "FALSE" })),
|
||||
ICalendarValue::PartialDateTime(dt) => dt.to_rfc3339().map(Cow::Owned),
|
||||
ICalendarValue::RecurrenceRule(rrule) => Some(Cow::Owned(rrule.to_string())),
|
||||
ICalendarValue::Binary(value) => String::from_utf8(value).ok().map(Cow::Owned),
|
||||
ICalendarValue::Duration(value) => Some(Cow::Owned(value.to_string())),
|
||||
ICalendarValue::Period(value) => Some(Cow::Owned(value.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_partial_date_time(self) -> Option<Box<PartialDateTime>> {
|
||||
match self {
|
||||
ICalendarValue::PartialDateTime(dt) => Some(dt),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_integer(&self) -> Option<i64> {
|
||||
match self {
|
||||
ICalendarValue::Integer(i) => Some(*i),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_float(&self) -> Option<f64> {
|
||||
match self {
|
||||
ICalendarValue::Float(f) => Some(*f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_boolean(&self) -> Option<bool> {
|
||||
match self {
|
||||
ICalendarValue::Boolean(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_partial_date_time(&self) -> Option<&PartialDateTime> {
|
||||
match self {
|
||||
ICalendarValue::PartialDateTime(dt) => Some(dt),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_binary(&self) -> Option<&[u8]> {
|
||||
match self {
|
||||
ICalendarValue::Binary(d) => Some(d.as_slice()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ICalendarEntry {
|
||||
#[inline]
|
||||
pub fn parameters(
|
||||
&self,
|
||||
prop: &ICalendarParameterName,
|
||||
) -> impl Iterator<Item = &ICalendarParameterValue> {
|
||||
self.params.iter().filter_map(move |param| {
|
||||
if ¶m.name == prop {
|
||||
Some(¶m.value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn parameter(&self, prop: &ICalendarParameterName) -> Option<&ICalendarParameterValue> {
|
||||
self.params.iter().find_map(move |param| {
|
||||
if ¶m.name == prop {
|
||||
Some(¶m.value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn has_parameter(&self, prop: &ICalendarParameterName) -> bool {
|
||||
self.params.iter().any(|param| ¶m.name == prop)
|
||||
}
|
||||
|
||||
pub fn jsid(&self) -> Option<&str> {
|
||||
self.parameter(&ICalendarParameterName::Jsid)
|
||||
.and_then(|v| v.as_text())
|
||||
}
|
||||
|
||||
pub fn is_derived(&self) -> bool {
|
||||
self.parameter(&ICalendarParameterName::Derived)
|
||||
.is_some_and(|v| matches!(v, ICalendarParameterValue::Bool(true)))
|
||||
}
|
||||
|
||||
pub fn calendar_address(&self) -> Option<&str> {
|
||||
self.values
|
||||
.first()
|
||||
.and_then(|v| v.as_text())
|
||||
.map(strip_mailto_scheme)
|
||||
}
|
||||
|
||||
pub fn size(&self) -> usize {
|
||||
self.values.iter().map(|value| value.size()).sum::<usize>()
|
||||
+ self
|
||||
.params
|
||||
.iter()
|
||||
.map(|param| std::mem::size_of::<ICalendarParameterName>() + param.value.size())
|
||||
.sum::<usize>()
|
||||
+ self.name.as_str().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl ICalendarParameterValue {
|
||||
pub fn as_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
ICalendarParameterValue::Text(v) => Some(v.as_str()),
|
||||
ICalendarParameterValue::Bool(v) => Some(if *v { "TRUE" } else { "FALSE" }),
|
||||
ICalendarParameterValue::Uri(uri) => uri.as_str(),
|
||||
ICalendarParameterValue::Cutype(v) => Some(v.as_str()),
|
||||
ICalendarParameterValue::Fbtype(v) => Some(v.as_str()),
|
||||
ICalendarParameterValue::Partstat(v) => Some(v.as_str()),
|
||||
ICalendarParameterValue::Related(v) => Some(v.as_str()),
|
||||
ICalendarParameterValue::Reltype(v) => Some(v.as_str()),
|
||||
ICalendarParameterValue::Role(v) => Some(v.as_str()),
|
||||
ICalendarParameterValue::ScheduleAgent(v) => Some(v.as_str()),
|
||||
ICalendarParameterValue::ScheduleForceSend(v) => Some(v.as_str()),
|
||||
ICalendarParameterValue::Value(v) => Some(v.as_str()),
|
||||
ICalendarParameterValue::Display(v) => Some(v.as_str()),
|
||||
ICalendarParameterValue::Feature(v) => Some(v.as_str()),
|
||||
ICalendarParameterValue::Linkrel(v) => Some(v.as_str()),
|
||||
ICalendarParameterValue::Duration(_)
|
||||
| ICalendarParameterValue::Integer(_)
|
||||
| ICalendarParameterValue::Null => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_text(self) -> Option<Cow<'static, str>> {
|
||||
match self {
|
||||
ICalendarParameterValue::Text(v) => Some(Cow::Owned(v)),
|
||||
ICalendarParameterValue::Bool(v) => {
|
||||
Some(Cow::Borrowed(if v { "TRUE" } else { "FALSE" }))
|
||||
}
|
||||
ICalendarParameterValue::Uri(uri) => Some(Cow::Owned(uri.into_unwrapped_string())),
|
||||
ICalendarParameterValue::Cutype(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarParameterValue::Fbtype(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarParameterValue::Partstat(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarParameterValue::Related(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarParameterValue::Reltype(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarParameterValue::Role(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarParameterValue::ScheduleAgent(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarParameterValue::ScheduleForceSend(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarParameterValue::Value(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarParameterValue::Display(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarParameterValue::Feature(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarParameterValue::Linkrel(v) => Some(Cow::Borrowed(v.as_str())),
|
||||
ICalendarParameterValue::Duration(v) => Some(Cow::Owned(v.to_string())),
|
||||
ICalendarParameterValue::Integer(v) => Some(Cow::Owned(v.to_string())),
|
||||
ICalendarParameterValue::Null => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_value_type(self) -> Option<IanaType<ICalendarValueType, String>> {
|
||||
match self {
|
||||
ICalendarParameterValue::Value(v) => Some(IanaType::Iana(v)),
|
||||
ICalendarParameterValue::Text(v) => Some(IanaType::Other(v)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_integer(&self) -> Option<u64> {
|
||||
match self {
|
||||
ICalendarParameterValue::Integer(v) => Some(*v),
|
||||
ICalendarParameterValue::Text(v) => v.parse().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_bool(&self) -> Option<bool> {
|
||||
match self {
|
||||
ICalendarParameterValue::Bool(v) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(&self) -> usize {
|
||||
match self {
|
||||
ICalendarParameterValue::Text(v) => v.len(),
|
||||
ICalendarParameterValue::Uri(v) => v.size(),
|
||||
_ => std::mem::size_of::<ICalendarParameterValue>(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ICalendarComponentType {
|
||||
pub fn has_time_ranges(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ICalendarComponentType::VEvent
|
||||
| ICalendarComponentType::VTodo
|
||||
| ICalendarComponentType::VJournal
|
||||
| ICalendarComponentType::VFreebusy
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_scheduling_object(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ICalendarComponentType::VEvent
|
||||
| ICalendarComponentType::VTodo
|
||||
| ICalendarComponentType::VJournal
|
||||
| ICalendarComponentType::VFreebusy
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_event(&self) -> bool {
|
||||
matches!(self, ICalendarComponentType::VEvent)
|
||||
}
|
||||
|
||||
pub fn is_todo(&self) -> bool {
|
||||
matches!(self, ICalendarComponentType::VTodo)
|
||||
}
|
||||
|
||||
pub fn is_event_or_todo(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ICalendarComponentType::VEvent | ICalendarComponentType::VTodo
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_journal(&self) -> bool {
|
||||
matches!(self, ICalendarComponentType::VJournal)
|
||||
}
|
||||
|
||||
pub fn is_freebusy(&self) -> bool {
|
||||
matches!(self, ICalendarComponentType::VFreebusy)
|
||||
}
|
||||
|
||||
pub fn is_location(&self) -> bool {
|
||||
matches!(self, ICalendarComponentType::VLocation)
|
||||
}
|
||||
|
||||
pub fn is_alarm(&self) -> bool {
|
||||
matches!(self, ICalendarComponentType::VAlarm)
|
||||
}
|
||||
|
||||
pub fn is_participant(&self) -> bool {
|
||||
matches!(self, ICalendarComponentType::Participant)
|
||||
}
|
||||
}
|
||||
|
||||
impl Uri {
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Uri::Data(data) => data.content_type.as_deref(),
|
||||
Uri::Location(loc) => loc.as_str().into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(&self) -> usize {
|
||||
match self {
|
||||
Uri::Data(data) => {
|
||||
data.data.len() + data.content_type.as_ref().map(|s| s.len()).unwrap_or(0)
|
||||
}
|
||||
Uri::Location(loc) => loc.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ICalendarDuration {
|
||||
pub fn from_seconds(seconds: i64) -> Self {
|
||||
let mut secs = seconds;
|
||||
let neg = secs < 0;
|
||||
if neg {
|
||||
secs = -secs;
|
||||
}
|
||||
let weeks = (secs / 604800) as u32;
|
||||
secs %= 604800;
|
||||
let days = (secs / 86400) as u32;
|
||||
secs %= 86400;
|
||||
let hours = (secs / 3600) as u32;
|
||||
secs %= 3600;
|
||||
let minutes = (secs / 60) as u32;
|
||||
secs %= 60;
|
||||
let seconds = secs as u32;
|
||||
|
||||
Self {
|
||||
weeks,
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
neg,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_days(days: i64) -> Self {
|
||||
let neg = days < 0;
|
||||
let days = days.unsigned_abs();
|
||||
let (weeks, days) = if days.is_multiple_of(7) {
|
||||
(days / 7, 0)
|
||||
} else {
|
||||
(0, days)
|
||||
};
|
||||
|
||||
Self {
|
||||
weeks: weeks as u32,
|
||||
days: days as u32,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
neg,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_time_delta(&self) -> Option<chrono::TimeDelta> {
|
||||
chrono::TimeDelta::new(self.as_seconds(), 0)
|
||||
}
|
||||
|
||||
pub fn as_seconds(&self) -> i64 {
|
||||
let secs = self.seconds as i64
|
||||
+ self.minutes as i64 * 60
|
||||
+ self.hours as i64 * 3600
|
||||
+ self.days as i64 * 86400
|
||||
+ self.weeks as i64 * 604800;
|
||||
|
||||
if self.neg { -secs } else { secs }
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.weeks == 0
|
||||
&& self.days == 0
|
||||
&& self.hours == 0
|
||||
&& self.minutes == 0
|
||||
&& self.seconds == 0
|
||||
}
|
||||
}
|
||||
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user