Security release 2026.10.29.1 #233

Merged
jcoffey-dev merged 10 commits from hotfix/2026.10.29.1 into release/2026 2026-10-29 13:42:32 +00:00
146 changed files with 42462 additions and 211 deletions

No files matched your search

Generated
-2
View File
@@ -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",
+5
View File
@@ -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" }
+110 -14
View File
@@ -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
);
}
}
}
+599 -81
View File
@@ -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!(
+25
View File
@@ -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")]
+71 -2
View File
@@ -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"))
);
}
}
+11 -5
View File
@@ -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);
}
+41 -1
View File
@@ -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>>,
+161
View File
@@ -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"
);
}
}
}
}
}
+3
View File
@@ -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> {
+5 -2
View File
@@ -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
View File
@@ -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 -4
View File
@@ -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)
+5 -2
View File
@@ -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?
+11
View File
@@ -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,
),
+1
View File
@@ -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,
),
+5 -2
View File
@@ -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
+3 -2
View File
@@ -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 {
+5 -2
View File
@@ -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 {
+27 -2
View File
@@ -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(
+3 -1
View File
@@ -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
)
+5 -1
View File
@@ -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 {
+7 -3
View File
@@ -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) => {
+5 -2
View File
@@ -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
})
+3 -2
View File
@@ -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(
+5 -2
View File
@@ -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 {
+3 -2
View File
@@ -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,
+5 -2
View File
@@ -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,
+5 -2
View File
@@ -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 {
+1
View File
@@ -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;
+146
View File
@@ -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
+24 -7
View File
@@ -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) => {
+11 -1
View File
@@ -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();
+6 -2
View File
@@ -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
+15 -1
View File
@@ -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
+4 -1
View File
@@ -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![]
+110
View File
@@ -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)
);
}
}
+5
View File
@@ -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;
+11
View File
@@ -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 {
+53
View File
@@ -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! {
+7
View File
@@ -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,
+15 -1
View File
@@ -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 {
+11
View File
@@ -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)]
+62 -3
View File
@@ -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,
+26 -1
View File
@@ -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,
+5 -11
View File
@@ -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;
}
+13 -12
View File
@@ -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)
{
+1 -1
View File
@@ -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"
};
}
+27 -6
View File
@@ -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?
+2
View File
@@ -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
View File
@@ -1 +1 @@
F0Ba3aWEThPbP2e1Uy6eryGOZ-UxeLsPNJn9y2yGp4Y
sumxHhh5m_-OICu3KBWCIwgVgSpXxw-jcNAvXt63ATc
+178 -5
View File
@@ -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")
+3
View File
@@ -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");
+152
View File
@@ -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;
}
}
}
+13
View File
@@ -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!(
+173
View File
@@ -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
}
+3
View File
@@ -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;
+40 -4
View File
@@ -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
View File
@@ -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;
}
}
}
+12
View File
@@ -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
+117
View File
@@ -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.
+116
View File
@@ -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
View File
@@ -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.
+23
View File
@@ -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.
+224
View File
@@ -0,0 +1,224 @@
# calcard
[![crates.io](https://img.shields.io/crates/v/calcard)](https://crates.io/crates/calcard)
[![build](https://github.com/stalwartlabs/calcard/actions/workflows/rust.yml/badge.svg)](https://github.com/stalwartlabs/calcard/actions/workflows/rust.yml)
[![docs.rs](https://img.shields.io/docsrs/calcard)](https://docs.rs/calcard)
[![crates.io](https://img.shields.io/crates/l/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
+57
View File
@@ -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.
+101
View File
@@ -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())
}
}
+263
View File
@@ -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)
}
}
}
+606
View File
@@ -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)
);
}
}
}
File diff suppressed because it is too large. Load diff
+791
View File
@@ -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);
}
}
}
+451
View File
@@ -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",
}
}
}
+276
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+176
View File
@@ -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
View File
@@ -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(&current_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(&current_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(&current_month_day);
let filtered_by_n_month_day = !rrule.by_n_month_day.contains(&current_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
View File
@@ -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
}
}
+280
View File
@@ -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,
};
+74
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+267
View File
@@ -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
View File
@@ -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()
}
}
+194
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 &param.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}");
}
}
+902
View File
@@ -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,
}
File diff suppressed because it is too large. Load diff
+69
View File
@@ -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
View File
@@ -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
View File
@@ -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 &param.name == prop {
Some(&param.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
View File
@@ -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 == &param.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(&param.name);
}
match &param.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)
}
}
File diff suppressed because it is too large. Load diff
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