Security release 2026.10.29.1 #233
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",
|
||||
|
||||
@@ -86,12 +86,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 <[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.
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -87,7 +87,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
|
||||
#[macro_export]
|
||||
macro_rules! brand_version {
|
||||
() => {
|
||||
"2026.10.29"
|
||||
"2026.10.29.1"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user