Merge upstream/main into feat/external-file-mounts
Resolve conflicts between the external-file-mounts feature and upstream's D5/D7 refactor (per-file provenance, keyset pagination, cross-drive move gates, resource-access hook, folder-cascade lifecycle hook). Key resolutions: - FolderService::new now takes (repo, authz, file_lifecycle, mount_router); all callers + DI updated. - FileRetrievalService / FileManagementService keep both the mount_router and the new resource_access_hook / drive_repo / storage_usage wiring. - list_files_batch_with_perms: adapt the mount branch from offset- to keyset (after_name) pagination, mirroring paginate_mount_entries. - download_file_impl: keep upstream's &HeaderMap + `impl IntoResponse + use<>` signature, retain the mount-download branch. - Mount DTOs: the retired `owner_id` field maps onto created_by/updated_by (the mount owner) — the fields the frontend now uses for owner display. - admin/+page.svelte: keep upstream's user-delete modal + the 'mounts' tab. - Bump memmap2 0.9.10 -> 0.9.11 (RUSTSEC critical advisory fix) and regenerate Cargo.lock against the merged Cargo.toml.
This commit is contained in:
+446
-1
@@ -310,6 +310,10 @@ pub struct AzureStorageConfig {
|
||||
pub container: String,
|
||||
/// Optional SAS token (alternative to account key).
|
||||
pub sas_token: Option<String>,
|
||||
/// Optional custom endpoint (Azurite emulator, private deployments,
|
||||
/// benches). `None` = the public cloud URL derived from the account
|
||||
/// name. Mirrors S3's `endpoint_url`.
|
||||
pub endpoint_url: Option<String>,
|
||||
}
|
||||
|
||||
/// LRU local disk cache configuration for remote blob backends.
|
||||
@@ -470,6 +474,148 @@ pub struct AuthConfig {
|
||||
pub hash_parallelism: u32,
|
||||
/// Rate limiting / account lockout configuration
|
||||
pub rate_limit: RateLimitConfig,
|
||||
/// Allowlist of email domains accepted on the public `POST
|
||||
/// /api/auth/register` endpoint. Empty = no restriction (any
|
||||
/// domain is allowed). Entries are lowercased and trimmed at
|
||||
/// load time; matching is case-insensitive exact-match on the
|
||||
/// post-`@` part of the address.
|
||||
///
|
||||
/// This is DISTINCT from
|
||||
/// [`MagicLinkConfig::allowed_email_domains`], which gates who
|
||||
/// can be INVITED (email-typed grants + magic-link login for
|
||||
/// existing recipients). This list gates SELF-registration
|
||||
/// only. An operator can, for example, keep public registration
|
||||
/// open to `partner-a.com` and `partner-b.io` while allowing
|
||||
/// invitations to any domain — the two lists are independent.
|
||||
///
|
||||
/// Example: `["partner-a.com", "partner-b.io"]` — only
|
||||
/// addresses `<anything>@partner-a.com` or
|
||||
/// `<anything>@partner-b.io` can self-register; everything else
|
||||
/// is rejected with 403 `RegistrationDomainNotAllowed`.
|
||||
///
|
||||
/// Wildcards / subdomain semantics are intentionally out of
|
||||
/// scope (mirroring `MagicLinkConfig::allowed_email_domains`):
|
||||
/// `partner.com` does NOT match `eng.partner.com`. List every
|
||||
/// subdomain explicitly.
|
||||
///
|
||||
/// Env: `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` (comma-
|
||||
/// separated).
|
||||
pub registration_allowed_email_domains: Vec<String>,
|
||||
/// Additive auth-policy toggles the operator has opted into.
|
||||
/// Distinct from `allowed_auth_methods` (which enables/disables a
|
||||
/// method wholesale) — this vector composes policy switches that
|
||||
/// tweak the default auth behaviour. Empty = pure defaults in
|
||||
/// effect, matching legacy behaviour.
|
||||
///
|
||||
/// Vector shape (rather than one boolean per policy) so future
|
||||
/// switches can be added by appending a variant instead of
|
||||
/// growing the env-var surface — `OXICLOUD_AUTH_POLICIES=policy_a,policy_b`.
|
||||
/// Each variant's name carries its own polarity (`Permit...`,
|
||||
/// future `Require...` / `Deny...`); the field name stays neutral
|
||||
/// so a future deny-style policy reads correctly at the call site.
|
||||
///
|
||||
/// Env: `OXICLOUD_AUTH_POLICIES` (comma-separated).
|
||||
///
|
||||
/// Deprecated legacy alias: `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true`
|
||||
/// still adds `PermitMagicLinkForPasswordUsers` to the vector for
|
||||
/// backwards compatibility; emits a startup warning encouraging
|
||||
/// migration to the vector form.
|
||||
pub auth_policies: Vec<AuthPolicy>,
|
||||
/// Allowlist of self-service auth methods offered on the login
|
||||
/// page and accepted by their respective endpoints. Empty (the
|
||||
/// default) = both methods allowed, matching legacy behaviour.
|
||||
/// OIDC is orthogonal — controlled via `OxidcConfig::enabled`.
|
||||
///
|
||||
/// Semantics:
|
||||
/// * `AuthMethod::Password` allowed → `POST /api/auth/login`
|
||||
/// accepts credentials; password-based `register` works.
|
||||
/// * `AuthMethod::MagicLink` allowed → `POST /api/auth/magic-
|
||||
/// link/send` mints tokens; email-only `register` works.
|
||||
///
|
||||
/// A method NOT in the list returns 403 with a specific
|
||||
/// `error_type` (`PasswordLoginDisabled`,
|
||||
/// `MagicLinkLoginDisabled`) so frontends can render a
|
||||
/// contextual message rather than a generic auth error.
|
||||
///
|
||||
/// Startup guard: when `MagicLink` is in the list but
|
||||
/// `SmtpConfig::is_enabled()` is false, the server refuses to
|
||||
/// start. A magic-link policy without a mail sender is a
|
||||
/// misconfiguration that silently locks users out.
|
||||
///
|
||||
/// Env: `OXICLOUD_AUTH_METHODS` (comma-separated:
|
||||
/// `password,magic_link`). Alias: the older
|
||||
/// `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes
|
||||
/// Password from this list when set (backwards-compat).
|
||||
pub allowed_auth_methods: Vec<AuthMethod>,
|
||||
/// Require the user's email to be verified before login is
|
||||
/// permitted. When `true`, `POST /api/auth/login` returns 403
|
||||
/// `EmailNotVerified` for any account whose `email_verified_at`
|
||||
/// is NULL. Users can prove control by clicking a magic-link
|
||||
/// (which stamps `email_verified_at`) — so this composes with
|
||||
/// `AuthMethod::MagicLink` in the allowlist above to provide a
|
||||
/// verification path.
|
||||
///
|
||||
/// Admin-created users (`POST /api/admin/users`) and the
|
||||
/// first-run setup admin (`POST /api/setup`) get
|
||||
/// `email_verified_at = NOW()` at creation — admin fiat counts
|
||||
/// as verification, matching the OIDC-JIT convention.
|
||||
///
|
||||
/// Env: `OXICLOUD_REQUIRE_VERIFIED_EMAIL` (default `false`).
|
||||
pub require_verified_email: bool,
|
||||
}
|
||||
|
||||
/// Self-service auth method. Exposed as `AuthConfig::allowed_auth_methods`
|
||||
/// and parsed from `OXICLOUD_AUTH_METHODS` (comma-separated). OIDC is
|
||||
/// deliberately excluded — it lives in `OidcConfig` with its own gate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthMethod {
|
||||
Password,
|
||||
MagicLink,
|
||||
}
|
||||
|
||||
impl AuthMethod {
|
||||
/// Case-insensitive parse: accepts `password`, `magic_link`, and the
|
||||
/// dash form `magic-link` (some operators habitually use dashes).
|
||||
/// Unknown token returns `None` so the caller can log-and-skip.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"password" => Some(Self::Password),
|
||||
"magic_link" | "magic-link" | "magiclink" => Some(Self::MagicLink),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Additive auth-policy switches. Exposed as `AuthConfig::auth_policies`
|
||||
/// and parsed from `OXICLOUD_AUTH_POLICIES` (comma-separated). Each
|
||||
/// variant's name states its own polarity — `Permit...` grants an
|
||||
/// exception, future `Require...` / `Deny...` variants restrict.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthPolicy {
|
||||
/// Allow magic-link login for accounts that ALSO have a password
|
||||
/// configured. Off by default — magic-link is otherwise gated by
|
||||
/// `magic_link_eligibility()` to users without a password
|
||||
/// (mailbox-strength should not shadow a stronger credential).
|
||||
/// Enabling this weakens the password to mailbox-strength for
|
||||
/// affected accounts; opt-in only.
|
||||
///
|
||||
/// Deprecated legacy alias: `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true`
|
||||
/// adds this variant to the vector with a startup warning.
|
||||
PermitMagicLinkForPasswordUsers,
|
||||
}
|
||||
|
||||
impl AuthPolicy {
|
||||
/// Case-insensitive parse: accepts `permit_magic_link_for_password_users`
|
||||
/// (canonical) and the dash form. Unknown token returns `None` so
|
||||
/// the caller can log-and-skip.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"permit_magic_link_for_password_users" | "permit-magic-link-for-password-users" => {
|
||||
Some(Self::PermitMagicLinkForPasswordUsers)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate limiting and brute-force protection configuration.
|
||||
@@ -521,10 +667,30 @@ impl Default for AuthConfig {
|
||||
hash_time_cost: 3,
|
||||
hash_parallelism: 2,
|
||||
rate_limit: RateLimitConfig::default(),
|
||||
registration_allowed_email_domains: Vec::new(),
|
||||
auth_policies: Vec::new(),
|
||||
allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink],
|
||||
require_verified_email: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
/// True iff `method` is enabled (or the allowlist is empty — meaning
|
||||
/// "all methods allowed", matching pre-`OXICLOUD_AUTH_METHODS`
|
||||
/// behaviour when the operator hasn't opted in yet).
|
||||
pub fn is_method_allowed(&self, method: AuthMethod) -> bool {
|
||||
self.allowed_auth_methods.is_empty() || self.allowed_auth_methods.contains(&method)
|
||||
}
|
||||
|
||||
/// True iff `policy` has been opted into via `OXICLOUD_AUTH_POLICIES`
|
||||
/// (or its legacy alias). Default policies are OFF — the vector is
|
||||
/// additive only, no invert / defaults.
|
||||
pub fn has_policy(&self, policy: AuthPolicy) -> bool {
|
||||
self.auth_policies.contains(&policy)
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenID Connect (OIDC) configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OidcConfig {
|
||||
@@ -912,6 +1078,75 @@ pub struct FeaturesConfig {
|
||||
/// trash/search). OFF by default — opt-in per deployment.
|
||||
/// Env: `OXICLOUD_ENABLE_EXTERNAL_MOUNTS`.
|
||||
pub enable_external_mounts: bool,
|
||||
/// Expose `/api/admin/internal/*` test-only endpoints that trigger
|
||||
/// background sweeps on demand (storage-usage reconciliation, blob
|
||||
/// GC). Intended for Hurl / integration tests that need to wait
|
||||
/// for these maintenance jobs deterministically rather than
|
||||
/// polling the cached value. Off by default — these endpoints
|
||||
/// short-circuit the operator-visible cadence, so production
|
||||
/// deployments don't want them reachable. Env:
|
||||
/// `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS`.
|
||||
pub enable_admin_internal_endpoints: bool,
|
||||
/// Native WebDAV path segment that lists the caller's drives.
|
||||
///
|
||||
/// * Default `"@drive"` — bare `/webdav/` addresses the caller's
|
||||
/// default personal drive (back-compat). Drive listing lives at
|
||||
/// `/webdav/@drive/`; explicit drive at
|
||||
/// `/webdav/@drive/<uuid|name>/…`.
|
||||
/// * `""` (empty) — no default-drive shortcut. Bare `/webdav/`
|
||||
/// returns the drive listing; explicit drive at
|
||||
/// `/webdav/<uuid|name>/…`. Operators who don't want a "default
|
||||
/// drive" concept exposed via WebDAV pick this.
|
||||
/// * Any other string (e.g. `"drives"`) — same shape as the default,
|
||||
/// just with that path segment. Loaded via `trim_matches('/')`
|
||||
/// so operators can safely pass `"/drives/"`.
|
||||
///
|
||||
/// Env: `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`.
|
||||
pub webdav_drive_listing_prefix: String,
|
||||
|
||||
/// Background purge of expired `storage.role_grants` rows.
|
||||
///
|
||||
/// The AuthZ engine already filters expired grants out of every
|
||||
/// permission check at read time (`expires_at IS NULL OR
|
||||
/// expires_at > NOW()`), so leaving the rows in place is a
|
||||
/// hygiene issue — not a security one. This purge deletes rows
|
||||
/// whose `expires_at` is more than [`GrantCleanupConfig::grace_days`]
|
||||
/// in the past, preserving the audit / support answer to
|
||||
/// "what happened to my access?" for the grace window.
|
||||
///
|
||||
/// Enabled by default: expired-auth-row cleanup is a
|
||||
/// security-hygiene default, not opt-in.
|
||||
pub grant_cleanup: GrantCleanupConfig,
|
||||
}
|
||||
|
||||
/// Config for the daily expired-grant purge (see
|
||||
/// [`FeaturesConfig::grant_cleanup`]).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GrantCleanupConfig {
|
||||
/// Master switch. Env: `OXICLOUD_GRANT_CLEANUP_ENABLED`
|
||||
/// (default `true`).
|
||||
pub enabled: bool,
|
||||
/// Days past a grant's `expires_at` before the row is eligible
|
||||
/// for deletion. Env: `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS`
|
||||
/// (default `15`).
|
||||
///
|
||||
/// The recommendation is `> 15` — enough to answer
|
||||
/// support/audit questions about recently-lapsed grants without
|
||||
/// keeping dead rows forever.
|
||||
pub grace_days: u32,
|
||||
/// How often the daemon fires, in hours. Env:
|
||||
/// `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` (default `24`).
|
||||
pub interval_hours: u64,
|
||||
}
|
||||
|
||||
impl Default for GrantCleanupConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
grace_days: 15,
|
||||
interval_hours: 24,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FeaturesConfig {
|
||||
@@ -928,6 +1163,15 @@ impl Default for FeaturesConfig {
|
||||
expose_system_users: true, // Expose OxiCloud users as address book by default
|
||||
enable_video_thumbnails: true, // Video thumbs via ffmpeg (if detected)
|
||||
enable_external_mounts: false, // External mounts — opt-in, off by default
|
||||
// Test-only sweep triggers — strictly opt-in. Production
|
||||
// deployments do NOT need this; the periodic ticker handles
|
||||
// reconciliation transparently.
|
||||
enable_admin_internal_endpoints: false,
|
||||
// Back-compat with pre-multi-drive clients — bare `/webdav/`
|
||||
// maps to the caller's default drive; drive listing is
|
||||
// reachable at `/webdav/@drive/`.
|
||||
webdav_drive_listing_prefix: "@drive".to_string(),
|
||||
grant_cleanup: GrantCleanupConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1017,6 +1261,33 @@ impl Default for ContentSearchConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Search-results cache configuration — the per-user results-page cache
|
||||
/// inside `SearchService`, not the Tantivy content index above.
|
||||
///
|
||||
/// The cache is **byte-bounded**: each entry is weighed by the approximate
|
||||
/// heap size of its result page (see `search_results_entry_weight`) and moka
|
||||
/// evicts once the summed weight exceeds `max_bytes` — the same byte-budget
|
||||
/// pattern the file-content cache and the dedup manifest cache use. This
|
||||
/// replaced an entry-count capacity: with cache keys spanning
|
||||
/// user × query × offset × limit and up to 500 enriched rows per page, an
|
||||
/// entry count said nothing about resident memory (1000 entries could pin
|
||||
/// ~300 MB for the TTL). No entry-count knob is kept — bytes are the only
|
||||
/// dimension that matters here.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchCacheConfig {
|
||||
/// Byte budget for cached search-result pages. Default: 32 MiB.
|
||||
/// Env: `OXICLOUD_SEARCH_CACHE_MAX_BYTES`.
|
||||
pub max_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for SearchCacheConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_bytes: 32 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WASM plugin runtime configuration (M0 walking skeleton).
|
||||
///
|
||||
/// The runtime is doubly gated: it is only compiled when the `plugins` cargo
|
||||
@@ -1142,6 +1413,8 @@ pub struct AppConfig {
|
||||
pub i18n: I18nConfig,
|
||||
/// Content-search configuration (embedded full-text index)
|
||||
pub content_search: ContentSearchConfig,
|
||||
/// Search-results cache configuration (byte-bounded moka cache)
|
||||
pub search_cache: SearchCacheConfig,
|
||||
/// WASM plugin runtime configuration
|
||||
pub plugins: PluginConfig,
|
||||
/// Face-recognition (People) model configuration
|
||||
@@ -1198,6 +1471,7 @@ impl Default for AppConfig {
|
||||
magic_link: MagicLinkConfig::default(),
|
||||
i18n: I18nConfig::default(),
|
||||
content_search: ContentSearchConfig::default(),
|
||||
search_cache: SearchCacheConfig::default(),
|
||||
plugins: PluginConfig::default(),
|
||||
faces: FacesConfig::default(),
|
||||
}
|
||||
@@ -1437,6 +1711,110 @@ impl AppConfig {
|
||||
config.auth.rate_limit.lockout_duration_secs = val;
|
||||
}
|
||||
|
||||
// Registration email-domain allowlist. Distinct from
|
||||
// `OXICLOUD_EXTERNAL_EMAIL_DOMAINS` (which gates who can be
|
||||
// INVITED via grants + magic link) — this one gates who can
|
||||
// SELF-register via `POST /api/auth/register`. Empty = no
|
||||
// restriction. Same parse shape as the external-domains list:
|
||||
// comma-separated, lowercased, trimmed, empties dropped.
|
||||
if let Ok(v) = env::var("OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS") {
|
||||
config.auth.registration_allowed_email_domains = v
|
||||
.split(',')
|
||||
.map(|d| d.trim().to_ascii_lowercase())
|
||||
.filter(|d| !d.is_empty())
|
||||
.collect();
|
||||
}
|
||||
|
||||
// Self-service auth-method allowlist. Empty (unset) = both methods
|
||||
// allowed. Unknown tokens are logged-and-skipped; a completely
|
||||
// unparseable value falls back to the default rather than locking
|
||||
// the operator out. If the resulting list is empty (e.g. the
|
||||
// operator wrote `OXICLOUD_AUTH_METHODS=nope`), we restore the
|
||||
// default — a zero-method allowlist would refuse every login.
|
||||
if let Ok(v) = env::var("OXICLOUD_AUTH_METHODS") {
|
||||
let methods: Vec<AuthMethod> = v
|
||||
.split(',')
|
||||
.filter_map(|s| {
|
||||
let parsed = AuthMethod::parse(s);
|
||||
if parsed.is_none() && !s.trim().is_empty() {
|
||||
eprintln!(
|
||||
"⚠️ OXICLOUD_AUTH_METHODS: ignoring unknown token '{}' \
|
||||
(expected: password, magic_link)",
|
||||
s.trim()
|
||||
);
|
||||
}
|
||||
parsed
|
||||
})
|
||||
.collect();
|
||||
if methods.is_empty() {
|
||||
eprintln!(
|
||||
"⚠️ OXICLOUD_AUTH_METHODS parsed to an empty allowlist; \
|
||||
falling back to default (password, magic_link)"
|
||||
);
|
||||
} else {
|
||||
config.auth.allowed_auth_methods = methods;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy alias: OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true still
|
||||
// removes Password from the allowlist. Its main handling in the
|
||||
// OIDC config block below is preserved for the `login_options`
|
||||
// response; this line makes the effect apply uniformly through
|
||||
// `is_method_allowed(Password)` so services don't need to check
|
||||
// both flags.
|
||||
if let Ok(v) = env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN")
|
||||
&& v.parse::<bool>().unwrap_or(false)
|
||||
{
|
||||
config
|
||||
.auth
|
||||
.allowed_auth_methods
|
||||
.retain(|m| *m != AuthMethod::Password);
|
||||
}
|
||||
|
||||
if let Ok(v) = env::var("OXICLOUD_REQUIRE_VERIFIED_EMAIL") {
|
||||
config.auth.require_verified_email = v.parse::<bool>().unwrap_or(false);
|
||||
}
|
||||
|
||||
// Auth-policy vector. Additive — each recognised token adds a
|
||||
// variant; unknown tokens are logged-and-skipped so a typo
|
||||
// doesn't silently zero the whole vector (an operator wanting
|
||||
// "no policies" simply doesn't set the env var).
|
||||
//
|
||||
// The legacy alias
|
||||
// `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true` is applied
|
||||
// AFTER this block (see the MagicLinkConfig section below) so a
|
||||
// deployment setting BOTH env vars ends up with a single copy
|
||||
// of `PermitMagicLinkForPasswordUsers` regardless of order.
|
||||
if let Ok(v) = env::var("OXICLOUD_AUTH_POLICIES") {
|
||||
for token in v.split(',') {
|
||||
match AuthPolicy::parse(token) {
|
||||
Some(policy) => {
|
||||
if !config.auth.auth_policies.contains(&policy) {
|
||||
config.auth.auth_policies.push(policy);
|
||||
}
|
||||
}
|
||||
None if !token.trim().is_empty() => {
|
||||
eprintln!(
|
||||
"⚠️ OXICLOUD_AUTH_POLICIES: ignoring unknown token '{}' \
|
||||
(known: permit_magic_link_for_password_users)",
|
||||
token.trim()
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// Reflect the vector into the legacy magic_link config field
|
||||
// so `magic_link_eligibility()` (the site that reads the
|
||||
// boolean today) doesn't need to know about the new form.
|
||||
if config
|
||||
.auth
|
||||
.auth_policies
|
||||
.contains(&AuthPolicy::PermitMagicLinkForPasswordUsers)
|
||||
{
|
||||
config.magic_link.open_to_password_users = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Feature flags
|
||||
if let Ok(enable_auth) = env::var("OXICLOUD_ENABLE_AUTH").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = enable_auth
|
||||
@@ -1489,6 +1867,44 @@ impl AppConfig {
|
||||
config.features.enable_video_thumbnails = val;
|
||||
}
|
||||
|
||||
// `/api/admin/internal/*` test-only triggers. Disabled by
|
||||
// default; production deployments never need this. The Hurl
|
||||
// suite flips it on via `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true`.
|
||||
if let Ok(enable_internal) =
|
||||
env::var("OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = enable_internal
|
||||
{
|
||||
config.features.enable_admin_internal_endpoints = val;
|
||||
}
|
||||
|
||||
// Grant-cleanup daemon. Purges rows from `storage.role_grants`
|
||||
// whose `expires_at` is more than `grace_days` in the past.
|
||||
// See `GrantCleanupConfig` for defaults + rationale.
|
||||
if let Ok(v) = env::var("OXICLOUD_GRANT_CLEANUP_ENABLED").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.features.grant_cleanup.enabled = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_GRANT_CLEANUP_GRACE_DAYS").map(|v| v.parse::<u32>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.features.grant_cleanup.grace_days = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS").map(|v| v.parse::<u64>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.features.grant_cleanup.interval_hours = val.max(1);
|
||||
}
|
||||
|
||||
// Native WebDAV drive-picker path segment. Sanitised by
|
||||
// stripping leading/trailing slashes so operators can pass
|
||||
// `/drives/` or `drives` interchangeably; empty string means
|
||||
// "no default-drive shortcut, `/webdav/` IS the drive listing".
|
||||
// See `FeaturesConfig::webdav_drive_listing_prefix`.
|
||||
if let Ok(raw) = env::var("OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX") {
|
||||
config.features.webdav_drive_listing_prefix = raw.trim_matches('/').to_string();
|
||||
}
|
||||
|
||||
if let Ok(enable_faces) = env::var("OXICLOUD_ENABLE_FACES").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = enable_faces
|
||||
{
|
||||
@@ -1566,6 +1982,13 @@ impl AppConfig {
|
||||
config.content_search.max_text_bytes = val;
|
||||
}
|
||||
|
||||
// Search-results cache (byte-bounded)
|
||||
if let Ok(v) = env::var("OXICLOUD_SEARCH_CACHE_MAX_BYTES").map(|v| v.parse::<u64>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.search_cache.max_bytes = val;
|
||||
}
|
||||
|
||||
// WASM plugin runtime
|
||||
if let Ok(v) = env::var("OXICLOUD_ENABLE_PLUGINS").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = v
|
||||
@@ -1735,6 +2158,7 @@ impl AppConfig {
|
||||
account_key: env::var("OXICLOUD_AZURE_ACCOUNT_KEY").unwrap_or_default(),
|
||||
container,
|
||||
sas_token: env::var("OXICLOUD_AZURE_SAS_TOKEN").ok(),
|
||||
endpoint_url: env::var("OXICLOUD_AZURE_ENDPOINT_URL").ok(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1970,8 +2394,29 @@ impl AppConfig {
|
||||
{
|
||||
config.magic_link.send_per_ip_per_hour = n;
|
||||
}
|
||||
// Legacy alias — writes the same effect as
|
||||
// `OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users`.
|
||||
// Warn once at boot so operators know to migrate before we drop
|
||||
// the old var. Kept indefinitely for compat, but the encouraged
|
||||
// form is the vector.
|
||||
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS") {
|
||||
config.magic_link.open_to_password_users = v == "true" || v == "1";
|
||||
let enabled = v == "true" || v == "1";
|
||||
config.magic_link.open_to_password_users = enabled;
|
||||
if enabled
|
||||
&& !config
|
||||
.auth
|
||||
.auth_policies
|
||||
.contains(&AuthPolicy::PermitMagicLinkForPasswordUsers)
|
||||
{
|
||||
config
|
||||
.auth
|
||||
.auth_policies
|
||||
.push(AuthPolicy::PermitMagicLinkForPasswordUsers);
|
||||
}
|
||||
eprintln!(
|
||||
"⚠️ OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS is deprecated. \
|
||||
Use `OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users` instead."
|
||||
);
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE") {
|
||||
config.magic_link.notify_internal_users_on_share = v == "true" || v == "1";
|
||||
|
||||
+313
-44
@@ -1,9 +1,13 @@
|
||||
use sqlx::PgPool;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::blob_storage_ports::BlobStorageBackend;
|
||||
use crate::application::ports::storage_ports::StorageUsagePort;
|
||||
use crate::common::config::StorageBackendType;
|
||||
use crate::domain::entities::drive::DriveKind;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::infrastructure::db::DbPools;
|
||||
|
||||
use crate::application::services::admin_settings_service::AdminSettingsService;
|
||||
@@ -50,13 +54,13 @@ use crate::application::ports::video_frame_ports::VideoFramePort;
|
||||
use crate::application::services::app_password_service::AppPasswordService;
|
||||
use crate::application::services::blob_lifecycle_service::BlobLifecycleService;
|
||||
use crate::application::services::calendar_service::CalendarService;
|
||||
use crate::application::services::contact_service::ContactService;
|
||||
use crate::application::services::device_auth_service::DeviceAuthService;
|
||||
use crate::application::services::file_lifecycle_service::FileLifecycleService;
|
||||
use crate::application::services::music_service::MusicService;
|
||||
use crate::application::services::storage_usage_service::StorageUsageService;
|
||||
use crate::application::services::wopi_lock_service::WopiLockService;
|
||||
use crate::application::services::wopi_token_service::WopiTokenService;
|
||||
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
|
||||
use crate::infrastructure::repositories::AppPasswordPgRepository;
|
||||
use crate::infrastructure::repositories::DeviceCodePgRepository;
|
||||
use crate::infrastructure::repositories::pg::{
|
||||
@@ -519,46 +523,83 @@ impl AppServiceFactory {
|
||||
Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort>,
|
||||
>,
|
||||
mount_router: Arc<crate::application::services::external_mount_router::MountRouter>,
|
||||
resource_access_hook: Option<
|
||||
Arc<dyn crate::application::ports::resource_access_hook::ResourceAccessHook>,
|
||||
>,
|
||||
) -> ApplicationServices {
|
||||
// Main services
|
||||
let folder_service = Arc::new(FolderService::new(
|
||||
repos.folder_repository.clone(),
|
||||
authz.clone(),
|
||||
mount_router.clone(),
|
||||
));
|
||||
let folder_service = Arc::new(
|
||||
FolderService::new(
|
||||
repos.folder_repository.clone(),
|
||||
authz.clone(),
|
||||
// Same dispatcher TrashService uses, so the cascade hook in
|
||||
// `delete_folder_with_perms` fans out to the same handlers
|
||||
// (thumbnails, metadata, …) as a single-file delete.
|
||||
core.file_lifecycle.clone(),
|
||||
mount_router.clone(),
|
||||
)
|
||||
// D5 cross-drive move gate reads policies via the same
|
||||
// drive repo every other policy uses. Wired here so
|
||||
// `move_folder_with_perms` can enforce
|
||||
// `forbid_cross_drive_move` without a separate construction path.
|
||||
.with_drive_repo(drive_repo.clone())
|
||||
// Destination-drive quota pre-check on cross-drive folder
|
||||
// MOVE. Reuses the `check_drive_quota` the upload path
|
||||
// already runs. Without this, a Move that would push the
|
||||
// destination past its cap succeeds silently.
|
||||
.with_storage_usage(storage_usage.clone()),
|
||||
);
|
||||
|
||||
// Built before the upload/management services so the plugin lifecycle
|
||||
// bridge (which looks file metadata up by id) can be wired into the
|
||||
// dispatcher they receive. It depends only on repos + core, never on
|
||||
// the upload service, so the reorder is safe.
|
||||
let file_retrieval_service = Arc::new(
|
||||
FileRetrievalService::new_with_cache(
|
||||
let file_retrieval_service = {
|
||||
let mut svc = FileRetrievalService::new_with_cache(
|
||||
repos.file_read_repository.clone(),
|
||||
core.file_content_cache.clone(),
|
||||
core.image_transcode_service.clone(),
|
||||
authz.clone(),
|
||||
)
|
||||
.with_mount_router(mount_router.clone()),
|
||||
);
|
||||
.with_mount_router(mount_router.clone());
|
||||
if let Some(hook) = resource_access_hook.clone() {
|
||||
svc = svc.with_resource_access_hook(hook);
|
||||
}
|
||||
Arc::new(svc)
|
||||
};
|
||||
|
||||
// Effective lifecycle dispatcher: the core hooks (thumbnails, metadata)
|
||||
// plus, when the plugins feature is enabled, the WASM plugin bridge.
|
||||
let file_lifecycle =
|
||||
self.effective_file_lifecycle(core, &file_retrieval_service, plugin_dispatch);
|
||||
|
||||
let file_upload_service = Arc::new(
|
||||
FileUploadService::new_with_read(
|
||||
let file_upload_service = Arc::new({
|
||||
let mut svc = FileUploadService::new_with_read(
|
||||
repos.file_write_repository.clone(),
|
||||
repos.file_read_repository.clone(),
|
||||
)
|
||||
.with_content_cache(core.file_content_cache.clone())
|
||||
.with_file_lifecycle_hook(file_lifecycle.clone())
|
||||
// `with_storage_usage_service` wires the post-write delta
|
||||
// hook (`maybe_update_storage_usage`). Without this the
|
||||
// hook is dead code — both per-user and per-drive
|
||||
// `used_bytes` deltas would silently no-op and the
|
||||
// counters drift until the next reconciliation sweep
|
||||
// (default 10 min). `with_instant_upload` below stashes
|
||||
// the same service under a different field used only by
|
||||
// the dedup-instant-upload check, so they're not
|
||||
// interchangeable.
|
||||
.with_storage_usage_service(storage_usage.clone())
|
||||
.with_instant_upload(
|
||||
authz.clone(),
|
||||
core.dedup_service.clone(),
|
||||
storage_usage.clone(),
|
||||
),
|
||||
);
|
||||
);
|
||||
if let Some(hook) = resource_access_hook.clone() {
|
||||
svc = svc.with_resource_access_hook(hook);
|
||||
}
|
||||
svc
|
||||
});
|
||||
|
||||
// Delta-upload protocol — chunk negotiation over the same dedup
|
||||
// store. Bounded by the same whole-file ceiling as byte uploads.
|
||||
@@ -575,8 +616,8 @@ impl AppServiceFactory {
|
||||
);
|
||||
|
||||
// FileManagementService — ref_count handled by PG trigger, no dedup port needed
|
||||
let file_management_service = Arc::new(
|
||||
FileManagementService::with_trash(
|
||||
let file_management_service = Arc::new({
|
||||
let mut svc = FileManagementService::with_trash(
|
||||
repos.file_write_repository.clone(),
|
||||
trash_service.clone(),
|
||||
Some(repos.file_read_repository.clone()),
|
||||
@@ -585,8 +626,20 @@ impl AppServiceFactory {
|
||||
authz.clone(),
|
||||
)
|
||||
.with_file_lifecycle_hook(file_lifecycle.clone())
|
||||
.with_mount_router(mount_router.clone()),
|
||||
);
|
||||
.with_mount_router(mount_router.clone())
|
||||
// D5 cross-drive move gate reads policies via the same
|
||||
// drive repo every other policy uses. Wired here so
|
||||
// `move_file_with_perms` can enforce `forbid_cross_drive_move`
|
||||
// without a separate construction path.
|
||||
.with_drive_repo(drive_repo.clone())
|
||||
// Destination-drive quota pre-check on cross-drive file
|
||||
// MOVE. Same rationale as the folder side above.
|
||||
.with_storage_usage(storage_usage.clone());
|
||||
if let Some(hook) = resource_access_hook.clone() {
|
||||
svc = svc.with_resource_access_hook(hook);
|
||||
}
|
||||
svc
|
||||
});
|
||||
|
||||
// Streams uploads to external mount providers (bypasses the CAS).
|
||||
let external_upload_service = Arc::new(
|
||||
@@ -614,8 +667,12 @@ impl AppServiceFactory {
|
||||
content_index_port,
|
||||
Some(authz.clone()),
|
||||
Some(drive_repo.clone()),
|
||||
300, // Cache TTL in seconds (5 minutes)
|
||||
1000, // Maximum cache entries
|
||||
300, // Cache TTL in seconds (5 minutes)
|
||||
// Byte budget for cached result pages (weigher-bounded, 32 MiB
|
||||
// default; env OXICLOUD_SEARCH_CACHE_MAX_BYTES). Replaces the old
|
||||
// entry-count capacity, which let 500-row pages keyed by
|
||||
// user×query×offset×limit pin hundreds of MB for the TTL.
|
||||
self.config.search_cache.max_bytes,
|
||||
)));
|
||||
|
||||
tracing::info!("Application services initialized");
|
||||
@@ -795,10 +852,8 @@ impl AppServiceFactory {
|
||||
let service = Arc::new(
|
||||
TrashService::new(
|
||||
trash_repo.clone(),
|
||||
repos.file_read_repository.clone(),
|
||||
repos.file_write_repository.clone(),
|
||||
repos.folder_repository.clone(),
|
||||
self.config.storage.trash_retention_days,
|
||||
core.dedup_service.clone(),
|
||||
Some(core.file_content_cache.clone()),
|
||||
authz.clone(),
|
||||
@@ -828,6 +883,7 @@ impl AppServiceFactory {
|
||||
repos: &RepositoryServices,
|
||||
db_pool: &Arc<PgPool>,
|
||||
authorization: &Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||
drive_repo: &Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||
) -> Option<Arc<ShareService>> {
|
||||
if !self.config.features.enable_file_sharing {
|
||||
tracing::info!("File sharing service is disabled in configuration");
|
||||
@@ -850,6 +906,7 @@ impl AppServiceFactory {
|
||||
share_repository,
|
||||
repos.file_read_repository.clone(),
|
||||
repos.folder_repository.clone(),
|
||||
drive_repo.clone(),
|
||||
password_hasher,
|
||||
authorization.clone(),
|
||||
));
|
||||
@@ -858,30 +915,49 @@ impl AppServiceFactory {
|
||||
Some(service)
|
||||
}
|
||||
|
||||
/// Creates the favorites service (requires database)
|
||||
pub fn create_favorites_service(&self, db_pool: &Arc<PgPool>) -> Arc<FavoritesService> {
|
||||
/// Creates the favorites service (requires database + authz engine
|
||||
/// for the Read gate on `add_to_favorites` — see the post-Drive
|
||||
/// AuthZ audit).
|
||||
pub fn create_favorites_service(
|
||||
&self,
|
||||
db_pool: &Arc<PgPool>,
|
||||
authorization: &Arc<PgAclEngine>,
|
||||
) -> Arc<FavoritesService> {
|
||||
let repo = Arc::new(
|
||||
crate::infrastructure::repositories::pg::FavoritesPgRepository::new(db_pool.clone()),
|
||||
);
|
||||
let service = Arc::new(FavoritesService::new(repo));
|
||||
let service = Arc::new(FavoritesService::new(repo, authorization.clone()));
|
||||
tracing::info!("Favorites service initialized");
|
||||
service
|
||||
}
|
||||
|
||||
/// Creates the recent items service (requires database)
|
||||
pub fn create_recent_service(&self, db_pool: &Arc<PgPool>) -> Arc<RecentService> {
|
||||
/// Creates the recent items service (requires database + authz
|
||||
/// engine for the Read gate on `record_item_access` — see the
|
||||
/// post-Drive AuthZ audit).
|
||||
pub fn create_recent_service(
|
||||
&self,
|
||||
db_pool: &Arc<PgPool>,
|
||||
authorization: &Arc<PgAclEngine>,
|
||||
) -> Arc<RecentService> {
|
||||
let repo = Arc::new(
|
||||
crate::infrastructure::repositories::pg::RecentItemsPgRepository::new(db_pool.clone()),
|
||||
);
|
||||
let service = Arc::new(RecentService::new(
|
||||
repo, 50, // Maximum recent items per user
|
||||
repo,
|
||||
authorization.clone(),
|
||||
50, // Maximum recent items per user
|
||||
));
|
||||
tracing::info!("Recent items service initialized");
|
||||
service
|
||||
}
|
||||
|
||||
/// Creates the Places (photo map) service. Reuses the existing file-read
|
||||
/// repository — the data is the caller's own geotagged photos.
|
||||
/// repository — the data is the caller's Photos-scope geotagged photos
|
||||
/// (§15: default personal drive + drives with
|
||||
/// `include_in_photo_index = true` AND caller has Read).
|
||||
/// Group-membership expansion is inline in the SQL via
|
||||
/// `storage.caller_group_ids`, so the service needs no AuthZ engine
|
||||
/// handle.
|
||||
pub fn create_places_service(
|
||||
&self,
|
||||
file_read: &Arc<FileBlobReadRepository>,
|
||||
@@ -992,14 +1068,26 @@ impl AppServiceFactory {
|
||||
_repos: &RepositoryServices,
|
||||
db_pool: &Arc<PgPool>,
|
||||
maintenance_pool: &Arc<PgPool>,
|
||||
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||
) -> Arc<StorageUsageService> {
|
||||
let user_repository = Arc::new(
|
||||
crate::infrastructure::repositories::pg::UserPgRepository::new(db_pool.clone()),
|
||||
);
|
||||
// The `drive_repo` passed in is the SAME instance held on
|
||||
// `AppState`, so its `readable_cache` / `default_drive_cache`
|
||||
// are the caches the request path reads from. A separately
|
||||
// constructed `DrivePgRepository` would have its OWN caches
|
||||
// and invalidation would be a no-op observed by nobody —
|
||||
// this is the trap that regressed the used_bytes freshness
|
||||
// after perf commit `12dc648c`.
|
||||
let service = Arc::new(
|
||||
crate::application::services::storage_usage_service::StorageUsageService::new(
|
||||
maintenance_pool.clone(),
|
||||
user_repository,
|
||||
)
|
||||
.with_drive_repo(
|
||||
drive_repo
|
||||
as Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||
),
|
||||
);
|
||||
// Keep cached storage usage fresh off the request path: GET /api/auth/me
|
||||
@@ -1135,6 +1223,10 @@ impl AppServiceFactory {
|
||||
// because services hold an Arc<PgAclEngine> for ReBAC checks.
|
||||
// SubjectGroupPgRepository is constructed here too so the engine can
|
||||
// expand a user's transitive group set on cache misses.
|
||||
//
|
||||
// Moved above the eager recent-service build so `create_recent_service`
|
||||
// can receive an `Arc<PgAclEngine>` — the Read gate on
|
||||
// `record_item_access` (post-Drive AuthZ audit fix) needs it.
|
||||
let subject_group_repo = Arc::new(
|
||||
crate::infrastructure::repositories::pg::SubjectGroupPgRepository::new(pool.clone()),
|
||||
);
|
||||
@@ -1145,6 +1237,29 @@ impl AppServiceFactory {
|
||||
subject_group_repo.clone(),
|
||||
);
|
||||
|
||||
// Recent service + recording hook are built up-front so the
|
||||
// hook can be threaded into `create_application_services` below.
|
||||
// The file services hold the hook directly so every authorised
|
||||
// `_with_perms` read/write fires into `auth.user_recent_files`
|
||||
// without per-handler wiring.
|
||||
//
|
||||
// The back-edge `recent_service_eager.set_resource_access_hook`
|
||||
// closes the loop so the clear/remove handlers can drop the
|
||||
// hook's in-memory throttle entries — without it a freshly
|
||||
// cleared Recent list refuses to re-record the same file for a
|
||||
// full TTL window, surfacing as "I cleared, opened the file,
|
||||
// and Recent is still empty" (caught by tests/api/recent.hurl
|
||||
// step 8).
|
||||
let recent_service_eager = self.create_recent_service(&pool, &authorization);
|
||||
let resource_access_hook: Arc<
|
||||
dyn crate::application::ports::resource_access_hook::ResourceAccessHook,
|
||||
> = Arc::new(
|
||||
crate::infrastructure::services::recent_recording_hook::RecentRecordingHook::new(
|
||||
recent_service_eager.clone(),
|
||||
),
|
||||
);
|
||||
recent_service_eager.set_resource_access_hook(resource_access_hook.clone());
|
||||
|
||||
// Drive repository — needed both by the lifecycle hook (when auth
|
||||
// is enabled) and by `GET /api/drives` on the final `AppState`,
|
||||
// so declared at the outer scope.
|
||||
@@ -1159,7 +1274,8 @@ impl AppServiceFactory {
|
||||
// 3c. Storage usage / quota service (needed by the instant-upload
|
||||
// path inside the application services, and re-exposed on AppState
|
||||
// for the handler-side quota checks of the byte-upload paths).
|
||||
let storage_usage = self.create_storage_usage_service(&repos, &pool, &maintenance_pool);
|
||||
let storage_usage =
|
||||
self.create_storage_usage_service(&repos, &pool, &maintenance_pool, drive_repo.clone());
|
||||
|
||||
// 3d. Content index (embedded Tantivy) — opened before application
|
||||
// services so SearchService can hold the query port; the feeding
|
||||
@@ -1205,10 +1321,11 @@ impl AppServiceFactory {
|
||||
content_index.as_ref().map(|(idx, _)| idx.clone()),
|
||||
plugin_dispatch.clone(),
|
||||
mount_router.clone(),
|
||||
Some(resource_access_hook.clone()),
|
||||
);
|
||||
|
||||
// 5. Share service
|
||||
let share_service = self.create_share_service(&repos, &pool, &authorization);
|
||||
let share_service = self.create_share_service(&repos, &pool, &authorization, &drive_repo);
|
||||
apps.share_service = share_service.clone();
|
||||
|
||||
let share_browse_service = share_service.as_ref().map(|s| {
|
||||
@@ -1226,6 +1343,9 @@ impl AppServiceFactory {
|
||||
let places_service: Option<Arc<PlacesService>>;
|
||||
let people_service: Option<Arc<PeopleService>>;
|
||||
let storage_usage_service: Option<Arc<StorageUsageService>>;
|
||||
let grant_cleanup_service: Option<
|
||||
Arc<crate::infrastructure::services::grant_cleanup_service::GrantCleanupService>,
|
||||
>;
|
||||
let mut auth_services: Option<crate::common::di::AuthServices> = None;
|
||||
let mut nextcloud_services: Option<NextcloudServices> = None;
|
||||
// Lifted out of the database-services block so PR 9's invite
|
||||
@@ -1239,13 +1359,15 @@ impl AppServiceFactory {
|
||||
> = None;
|
||||
|
||||
{
|
||||
let favs = self.create_favorites_service(&pool);
|
||||
let favs = self.create_favorites_service(&pool, &authorization);
|
||||
favorites_service = Some(favs.clone());
|
||||
apps.favorites_service = Some(favs);
|
||||
|
||||
let recent = self.create_recent_service(&pool);
|
||||
recent_service = Some(recent.clone());
|
||||
apps.recent_service = Some(recent);
|
||||
// Already built up-front so the file services could hold the
|
||||
// RecentRecordingHook — reuse the same Arc here so AppState and
|
||||
// the recording hook share one service instance.
|
||||
recent_service = Some(recent_service_eager.clone());
|
||||
apps.recent_service = Some(recent_service_eager.clone());
|
||||
|
||||
places_service = if core.config.features.enable_places {
|
||||
Some(self.create_places_service(&repos.file_read_repository))
|
||||
@@ -1267,6 +1389,25 @@ impl AppServiceFactory {
|
||||
|
||||
self.start_content_index_job(&maintenance_pool, &core, content_index);
|
||||
|
||||
grant_cleanup_service = if core.config.features.grant_cleanup.enabled {
|
||||
let svc = Arc::new(
|
||||
crate::infrastructure::services::grant_cleanup_service::GrantCleanupService::new(
|
||||
authorization.clone(),
|
||||
core.config.features.grant_cleanup.grace_days,
|
||||
core.config.features.grant_cleanup.interval_hours,
|
||||
),
|
||||
);
|
||||
// First tick fires immediately inside start_cleanup_job —
|
||||
// matches the trash/storage-usage daemon shape.
|
||||
svc.clone().start_cleanup_job().await;
|
||||
Some(svc)
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Grant-cleanup daemon disabled by OXICLOUD_GRANT_CLEANUP_ENABLED=false"
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
// User-lifecycle dispatcher. Hook order is registration order;
|
||||
// document dependencies inline if/when any arise. Today:
|
||||
// 1. AuditLifecycleHook — fires first so the
|
||||
@@ -1310,6 +1451,50 @@ impl AppServiceFactory {
|
||||
pool.clone(),
|
||||
),
|
||||
);
|
||||
|
||||
// CalDAV / CardDAV storage — constructed here (rather than in
|
||||
// block #10 below) so the two default-provisioning lifecycle
|
||||
// hooks can be wired into `user_lifecycle_builder` with the
|
||||
// rest of the chain. The Arcs are cloned into both the hooks
|
||||
// and, later, into their respective services — cheap and
|
||||
// matches the pattern used for `drive_repo` above.
|
||||
let calendar_repo_for_hook: Arc<
|
||||
crate::infrastructure::repositories::pg::CalendarPgRepository,
|
||||
> = Arc::new(
|
||||
crate::infrastructure::repositories::pg::CalendarPgRepository::new(pool.clone()),
|
||||
);
|
||||
let event_repo_for_hook: Arc<
|
||||
crate::infrastructure::repositories::pg::CalendarEventPgRepository,
|
||||
> = Arc::new(
|
||||
crate::infrastructure::repositories::pg::CalendarEventPgRepository::new(
|
||||
pool.clone(),
|
||||
),
|
||||
);
|
||||
let calendar_storage_for_hook = Arc::new(
|
||||
crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter::new(
|
||||
calendar_repo_for_hook.clone(),
|
||||
event_repo_for_hook.clone(),
|
||||
)
|
||||
);
|
||||
let address_book_repo_for_hook: Arc<AddressBookPgRepository> = Arc::new(
|
||||
crate::infrastructure::repositories::pg::AddressBookPgRepository::new(pool.clone()),
|
||||
);
|
||||
let contact_repo_for_hook: Arc<ContactPgRepository> = Arc::new(
|
||||
crate::infrastructure::repositories::pg::ContactPgRepository::new(pool.clone()),
|
||||
);
|
||||
let group_repo_for_hook: Arc<ContactGroupPgRepository> = Arc::new(
|
||||
crate::infrastructure::repositories::pg::ContactGroupPgRepository::new(
|
||||
pool.clone(),
|
||||
),
|
||||
);
|
||||
let contact_storage_for_hook = Arc::new(
|
||||
crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new(
|
||||
address_book_repo_for_hook.clone(),
|
||||
contact_repo_for_hook.clone(),
|
||||
group_repo_for_hook.clone(),
|
||||
),
|
||||
);
|
||||
|
||||
let mut user_lifecycle_builder =
|
||||
crate::application::services::user_lifecycle_service::UserLifecycleService::new()
|
||||
.with_hook(Arc::new(
|
||||
@@ -1321,6 +1506,19 @@ impl AppServiceFactory {
|
||||
authorization.clone(),
|
||||
),
|
||||
))
|
||||
.with_hook(Arc::new(
|
||||
crate::application::services::calendar_service::DefaultCalendarLifecycleHook::new(
|
||||
calendar_storage_for_hook.clone(),
|
||||
authorization.clone(),
|
||||
),
|
||||
))
|
||||
.with_hook(Arc::new(
|
||||
crate::application::services::contact_service::DefaultAddressBookLifecycleHook::new(
|
||||
address_book_repo_for_hook.clone(),
|
||||
contact_storage_for_hook.clone(),
|
||||
authorization.clone(),
|
||||
),
|
||||
))
|
||||
.with_hook(Arc::new(
|
||||
crate::infrastructure::services::pg_acl_engine::AuthzCacheLifecycleHook::new(
|
||||
authorization.clone(),
|
||||
@@ -1492,8 +1690,8 @@ impl AppServiceFactory {
|
||||
places_service,
|
||||
people_service,
|
||||
storage_usage_service,
|
||||
grant_cleanup_service,
|
||||
calendar_service: None,
|
||||
contact_service: None,
|
||||
calendar_use_case: None,
|
||||
addressbook_use_case: None,
|
||||
contact_use_case: None,
|
||||
@@ -1506,6 +1704,8 @@ impl AppServiceFactory {
|
||||
path_resolver: None,
|
||||
webdav_lock_store:
|
||||
crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(),
|
||||
webdav_dead_props:
|
||||
crate::infrastructure::services::webdav_dead_property_store::create_dead_property_store(pool.clone()),
|
||||
authorization: authorization.clone(),
|
||||
drive_repo: drive_repo.clone(),
|
||||
drive_management_service: Arc::new(
|
||||
@@ -1513,6 +1713,11 @@ impl AppServiceFactory {
|
||||
drive_repo.clone(),
|
||||
authorization.clone(),
|
||||
subject_group_repo.clone(),
|
||||
Arc::new(
|
||||
crate::infrastructure::repositories::pg::UserPgRepository::new(
|
||||
pool.clone(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
subject_group_service: Some(Arc::new(
|
||||
@@ -1525,6 +1730,7 @@ impl AppServiceFactory {
|
||||
),
|
||||
),
|
||||
authorization.clone(),
|
||||
drive_repo.clone(),
|
||||
),
|
||||
)),
|
||||
email_sender: None, // populated below
|
||||
@@ -1737,7 +1943,13 @@ impl AppServiceFactory {
|
||||
tracing::info!("PathResolver service initialized");
|
||||
}
|
||||
|
||||
// 10. Wire CalDAV/CardDAV services
|
||||
// 10. Wire CalDAV/CardDAV services. Note: the `*_for_hook`
|
||||
// adapters constructed inside the enable-auth block above
|
||||
// are out of scope here (that block ends before AppState
|
||||
// assembly). Re-constructing local adapters over the same
|
||||
// `pool` is cheap — the pool itself is shared via Arc, and
|
||||
// adapters are stateless delegators. Both instances end up
|
||||
// talking to the same rows.
|
||||
{
|
||||
// CalDAV
|
||||
let calendar_repo: Arc<CalendarPgRepository> = Arc::new(
|
||||
@@ -1757,6 +1969,7 @@ impl AppServiceFactory {
|
||||
let calendar_service = Arc::new(
|
||||
crate::application::services::calendar_service::CalendarService::new(
|
||||
calendar_storage,
|
||||
authorization.clone(),
|
||||
),
|
||||
);
|
||||
app_state.calendar_use_case = Some(calendar_service as Arc<CalendarService>);
|
||||
@@ -1778,10 +1991,12 @@ impl AppServiceFactory {
|
||||
address_book_repo,
|
||||
contact_repo,
|
||||
group_repo,
|
||||
)
|
||||
),
|
||||
);
|
||||
app_state.addressbook_use_case = Some(contact_storage.clone());
|
||||
app_state.contact_use_case = Some(contact_storage);
|
||||
let contact_service =
|
||||
Arc::new(ContactService::new(contact_storage, authorization.clone()));
|
||||
app_state.addressbook_use_case = Some(contact_service.clone());
|
||||
app_state.contact_use_case = Some(contact_service);
|
||||
|
||||
tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories");
|
||||
}
|
||||
@@ -1801,7 +2016,7 @@ impl AppServiceFactory {
|
||||
audio_metadata_repo,
|
||||
),
|
||||
);
|
||||
let music_svc = Arc::new(MusicService::new(music_storage));
|
||||
let music_svc = Arc::new(MusicService::new(music_storage, authorization.clone()));
|
||||
app_state.music_service = Some(music_svc);
|
||||
tracing::info!("Music service initialized");
|
||||
}
|
||||
@@ -1959,11 +2174,18 @@ pub struct AppState {
|
||||
pub places_service: Option<Arc<PlacesService>>,
|
||||
pub people_service: Option<Arc<PeopleService>>,
|
||||
pub storage_usage_service: Option<Arc<StorageUsageService>>,
|
||||
/// Handle to the background daemon that purges expired
|
||||
/// `storage.role_grants` rows. `None` when the daemon is disabled
|
||||
/// via `OXICLOUD_GRANT_CLEANUP_ENABLED=false`. The admin
|
||||
/// `POST /api/admin/internal/trigger-grant-cleanup` handler uses
|
||||
/// this to invoke the purge on demand (test-only).
|
||||
pub grant_cleanup_service: Option<
|
||||
Arc<crate::infrastructure::services::grant_cleanup_service::GrantCleanupService>,
|
||||
>,
|
||||
pub calendar_service: Option<Arc<CalendarService>>,
|
||||
pub contact_service: Option<Arc<ContactStorageAdapter>>,
|
||||
pub calendar_use_case: Option<Arc<CalendarService>>,
|
||||
pub addressbook_use_case: Option<Arc<ContactStorageAdapter>>,
|
||||
pub contact_use_case: Option<Arc<ContactStorageAdapter>>,
|
||||
pub addressbook_use_case: Option<Arc<ContactService>>,
|
||||
pub contact_use_case: Option<Arc<ContactService>>,
|
||||
pub music_service: Option<Arc<MusicService>>,
|
||||
pub wopi_token_service:
|
||||
Option<Arc<crate::application::services::wopi_token_service::WopiTokenService>>,
|
||||
@@ -1979,6 +2201,8 @@ pub struct AppState {
|
||||
Option<Arc<crate::infrastructure::services::path_resolver_service::PathResolverService>>,
|
||||
pub webdav_lock_store:
|
||||
Arc<crate::infrastructure::services::webdav_lock_service::WebDavLockStore>,
|
||||
pub webdav_dead_props:
|
||||
Arc<crate::infrastructure::services::webdav_dead_property_store::DeadPropertyStore>,
|
||||
/// ReBAC authorization engine — all service-layer permission checks go
|
||||
/// through this. Concrete type today is `PgAclEngine`; the
|
||||
/// `AuthorizationEngine` trait describes the contract. When alternate
|
||||
@@ -2063,6 +2287,51 @@ pub struct AppState {
|
||||
|
||||
// All AppState construction is done via struct literal in build_app_state().
|
||||
|
||||
impl AppState {
|
||||
/// Drive-aware RFC 4331 quota resolution — shared by the native and
|
||||
/// NextCloud-compatible WebDAV PROPFIND handlers so both surfaces
|
||||
/// report the same numbers for the same drive.
|
||||
///
|
||||
/// - `drive_id == Uuid::nil()`: synthetic drive-listing pseudo-root —
|
||||
/// no single drive, so the account envelope is the only defensible
|
||||
/// answer.
|
||||
/// - Personal drives carry no quota of their own (`Drive::quota_bytes`
|
||||
/// is NULL post-migration) — the account envelope in `auth.users`
|
||||
/// caps them.
|
||||
/// - Shared drives carry their own finite quota on `storage.drives` —
|
||||
/// report that, not the owner's unrelated personal envelope.
|
||||
///
|
||||
/// `available` is `None` for unlimited accounts/drives (quota <= 0 or
|
||||
/// unset) — RFC 4331 §3 lets a server omit `quota-available-bytes`
|
||||
/// rather than disclose a made-up value. Any lookup failure (quota
|
||||
/// subsystem disabled, drive gone) is treated the same way: quota is
|
||||
/// silently omitted rather than failing the whole PROPFIND.
|
||||
pub async fn resolve_webdav_quota(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
) -> Option<(i64, Option<i64>)> {
|
||||
let storage_svc = self.storage_usage_service.as_ref()?;
|
||||
|
||||
if drive_id.is_nil() {
|
||||
let (used, quota) = storage_svc.get_user_storage_info(user_id).await.ok()?;
|
||||
return Some((used, (quota > 0).then(|| (quota - used).max(0))));
|
||||
}
|
||||
|
||||
let drive = self.drive_repo.get_by_id(drive_id).await.ok()?.drive;
|
||||
match drive.kind {
|
||||
DriveKind::Personal => {
|
||||
let (used, quota) = storage_svc.get_user_storage_info(user_id).await.ok()?;
|
||||
Some((used, (quota > 0).then(|| (quota - used).max(0))))
|
||||
}
|
||||
DriveKind::Shared => {
|
||||
let used = drive.used_bytes;
|
||||
Some((used, drive.quota_bytes.map(|q| (q - used).max(0))))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the authorization engine. Today this only constructs `PgAclEngine`;
|
||||
/// the `OXICLOUD_AUTHZ_ENGINE` env var is reserved for future alternate
|
||||
/// implementations (e.g. `openfga`).
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
//! Heap-free fixed-layout formatters for the hot XML/HTTP emit paths.
|
||||
//!
|
||||
//! PROPFIND writes two formatted dates, a size and a quoted etag for
|
||||
//! EVERY row of every listing; `to_rfc3339()` / `to_rfc2822()` run
|
||||
//! chrono's format-spec interpreter and allocate a `String` each, and
|
||||
//! `u64::to_string()` allocates another. These helpers render the same
|
||||
//! bytes into a caller-provided stack buffer: zero heap traffic, no
|
||||
//! interpreter.
|
||||
//!
|
||||
//! Byte-identity with chrono (for whole-second in-range UTC datetimes)
|
||||
//! is asserted by the unit tests below and by the equivalence gate in
|
||||
//! `examples/bench_propfind_xml.rs`. Out-of-range seconds (negative or
|
||||
//! year > 9999, where the fixed-width layout no longer applies) return
|
||||
//! `None` — callers keep the old chrono path as fallback, so exotic
|
||||
//! values change nothing observable.
|
||||
|
||||
/// Seconds range rendering to a fixed-width 4-digit year: 1970-01-01
|
||||
/// through 9999-12-31 23:59:59 UTC.
|
||||
const MAX_4DIGIT_YEAR_SECS: i64 = 253_402_300_799;
|
||||
|
||||
const MONTHS: [&[u8; 3]; 12] = [
|
||||
b"Jan", b"Feb", b"Mar", b"Apr", b"May", b"Jun", b"Jul", b"Aug", b"Sep", b"Oct", b"Nov", b"Dec",
|
||||
];
|
||||
const WEEKDAYS: [&[u8; 3]; 7] = [b"Thu", b"Fri", b"Sat", b"Sun", b"Mon", b"Tue", b"Wed"];
|
||||
|
||||
/// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097); // day-of-era [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
/// Two-digit decimal pairs `"00" … "99"` — the same table-driven rendering
|
||||
/// `core::fmt` uses for integer `Display`. One lookup replaces a div+mod
|
||||
/// pair per two digits; ROUND10 adopted it after the naive div-by-10 loop
|
||||
/// benchmarked SLOWER than `u64::to_string()` (std already uses this LUT).
|
||||
const DEC_LUT: &[u8; 200] = b"0001020304050607080910111213141516171819\
|
||||
2021222324252627282930313233343536373839\
|
||||
4041424344454647484950515253545556575859\
|
||||
6061626364656667686970717273747576777879\
|
||||
8081828384858687888990919293949596979899";
|
||||
|
||||
#[inline]
|
||||
fn push2(out: &mut [u8], pos: usize, v: u32) {
|
||||
let d = (v as usize) * 2;
|
||||
out[pos] = DEC_LUT[d];
|
||||
out[pos + 1] = DEC_LUT[d + 1];
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn push4(out: &mut [u8], pos: usize, v: i64) {
|
||||
out[pos] = b'0' + (v / 1000 % 10) as u8;
|
||||
out[pos + 1] = b'0' + (v / 100 % 10) as u8;
|
||||
out[pos + 2] = b'0' + (v / 10 % 10) as u8;
|
||||
out[pos + 3] = b'0' + (v % 10) as u8;
|
||||
}
|
||||
|
||||
/// Split epoch seconds into (days, y, m, d, hh, mm, ss).
|
||||
#[inline]
|
||||
fn split(secs: i64) -> (i64, i64, u32, u32, u32, u32, u32) {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let sod = secs.rem_euclid(86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
(
|
||||
days,
|
||||
y,
|
||||
m,
|
||||
d,
|
||||
(sod / 3600) as u32,
|
||||
(sod / 60 % 60) as u32,
|
||||
(sod % 60) as u32,
|
||||
)
|
||||
}
|
||||
|
||||
/// `chrono::DateTime<Utc>::to_rfc3339()` for a whole-second timestamp:
|
||||
/// `2026-07-17T11:47:14+00:00` (25 bytes) written into `buf`.
|
||||
///
|
||||
/// Returns `None` when `secs` is outside the fixed-width range —
|
||||
/// callers fall back to chrono.
|
||||
pub fn rfc3339_utc(buf: &mut [u8; 25], secs: i64) -> Option<&str> {
|
||||
if !(0..=MAX_4DIGIT_YEAR_SECS).contains(&secs) {
|
||||
return None;
|
||||
}
|
||||
let (_days, y, m, d, hh, mm, ss) = split(secs);
|
||||
push4(buf, 0, y);
|
||||
buf[4] = b'-';
|
||||
push2(buf, 5, m);
|
||||
buf[7] = b'-';
|
||||
push2(buf, 8, d);
|
||||
buf[10] = b'T';
|
||||
push2(buf, 11, hh);
|
||||
buf[13] = b':';
|
||||
push2(buf, 14, mm);
|
||||
buf[16] = b':';
|
||||
push2(buf, 17, ss);
|
||||
buf[19..25].copy_from_slice(b"+00:00");
|
||||
// SAFETY-free: every byte written above is ASCII.
|
||||
Some(std::str::from_utf8(&buf[..]).expect("ascii"))
|
||||
}
|
||||
|
||||
/// `chrono::DateTime<Utc>::to_rfc2822()` for a whole-second timestamp:
|
||||
/// `Fri, 17 Jul 2026 11:47:14 +0000` written into `buf`.
|
||||
///
|
||||
/// chrono does NOT zero-pad the day (`Thu, 1 Jan 1970 …`), so the
|
||||
/// rendered length is 30 or 31 bytes — the round-4 PROPFIND equivalence
|
||||
/// gate caught an early padded version of this function; the sweep test
|
||||
/// below pins parity byte-for-byte across 60 years.
|
||||
pub fn rfc2822_utc(buf: &mut [u8; 31], secs: i64) -> Option<&str> {
|
||||
if !(0..=MAX_4DIGIT_YEAR_SECS).contains(&secs) {
|
||||
return None;
|
||||
}
|
||||
let (days, y, m, d, hh, mm, ss) = split(secs);
|
||||
let weekday = WEEKDAYS[days.rem_euclid(7) as usize];
|
||||
buf[0..3].copy_from_slice(weekday);
|
||||
buf[3] = b',';
|
||||
buf[4] = b' ';
|
||||
let mut p = 5;
|
||||
if d >= 10 {
|
||||
buf[p] = b'0' + (d / 10) as u8;
|
||||
p += 1;
|
||||
}
|
||||
buf[p] = b'0' + (d % 10) as u8;
|
||||
p += 1;
|
||||
buf[p] = b' ';
|
||||
p += 1;
|
||||
buf[p..p + 3].copy_from_slice(MONTHS[(m - 1) as usize]);
|
||||
p += 3;
|
||||
buf[p] = b' ';
|
||||
p += 1;
|
||||
push4(buf, p, y);
|
||||
p += 4;
|
||||
buf[p] = b' ';
|
||||
p += 1;
|
||||
push2(buf, p, hh);
|
||||
p += 2;
|
||||
buf[p] = b':';
|
||||
p += 1;
|
||||
push2(buf, p, mm);
|
||||
p += 2;
|
||||
buf[p] = b':';
|
||||
p += 1;
|
||||
push2(buf, p, ss);
|
||||
p += 2;
|
||||
buf[p..p + 6].copy_from_slice(b" +0000");
|
||||
p += 6;
|
||||
Some(std::str::from_utf8(&buf[..p]).expect("ascii"))
|
||||
}
|
||||
|
||||
/// Backward two-digit-chunk render of `v` into the tail of `buf`;
|
||||
/// returns the first populated index. Shared core of
|
||||
/// [`u64_str`] / [`i64_str`].
|
||||
#[inline]
|
||||
fn digits_to_tail(buf: &mut [u8], mut v: u64) -> usize {
|
||||
let mut pos = buf.len();
|
||||
while v >= 100 {
|
||||
let d = ((v % 100) as usize) * 2;
|
||||
v /= 100;
|
||||
pos -= 2;
|
||||
buf[pos] = DEC_LUT[d];
|
||||
buf[pos + 1] = DEC_LUT[d + 1];
|
||||
}
|
||||
if v >= 10 {
|
||||
let d = (v as usize) * 2;
|
||||
pos -= 2;
|
||||
buf[pos] = DEC_LUT[d];
|
||||
buf[pos + 1] = DEC_LUT[d + 1];
|
||||
} else {
|
||||
pos -= 1;
|
||||
buf[pos] = b'0' + v as u8;
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
/// `u64::to_string()` without the heap `String`: renders into `buf`,
|
||||
/// returns the populated tail slice.
|
||||
pub fn u64_str(buf: &mut [u8; 20], v: u64) -> &str {
|
||||
let pos = digits_to_tail(buf, v);
|
||||
std::str::from_utf8(&buf[pos..]).expect("ascii")
|
||||
}
|
||||
|
||||
/// `i64::to_string()` without the heap `String` (quota bytes are `i64`).
|
||||
pub fn i64_str(buf: &mut [u8; 21], v: i64) -> &str {
|
||||
let mut pos = digits_to_tail(buf, v.unsigned_abs());
|
||||
if v < 0 {
|
||||
pos -= 1;
|
||||
buf[pos] = b'-';
|
||||
}
|
||||
std::str::from_utf8(&buf[pos..]).expect("ascii")
|
||||
}
|
||||
|
||||
/// Lower-case hex of `bytes` into one preallocated `String`.
|
||||
///
|
||||
/// Replaces the `.map(|b| format!("{b:02x}")).collect()` shape, which heap-
|
||||
/// allocates a 2-byte `String` per digest byte (16 for MD5, 32 for SHA-256)
|
||||
/// before collect concatenates them.
|
||||
pub fn hex_lower(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for &b in bytes {
|
||||
out.push(HEX[(b >> 4) as usize] as char);
|
||||
out.push(HEX[(b & 0x0f) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `chrono::DateTime<Utc>::format("%Y%m%dT%H%M%SZ")` for a whole-second
|
||||
/// timestamp: the compact iCal/vCard UTC form `20260717T114714Z` (16 bytes)
|
||||
/// written into `buf`.
|
||||
///
|
||||
/// This is the `DTSTAMP` / `REV` / `CREATED` / `LAST-MODIFIED` stamp emitted
|
||||
/// per contact in every CardDAV vCard (`contact_to_vcard` / `generate_vcard`)
|
||||
/// and per event on the calendar create path. chrono's `.format("%Y%m%dT%H%M%SZ")`
|
||||
/// builds a `DelayedFormat` that re-parses the strftime spec (`StrftimeItems`)
|
||||
/// and formats six zero-padded fields through `core::fmt` on every call — the
|
||||
/// exact interpreter cost [`rfc3339_utc`] / [`rfc2822_utc`] were added to
|
||||
/// remove, but neither covers this compact no-separator form.
|
||||
///
|
||||
/// Returns `None` when `secs` is outside the fixed-width range —
|
||||
/// callers fall back to chrono.
|
||||
pub fn compact_ical_utc(buf: &mut [u8; 16], secs: i64) -> Option<&str> {
|
||||
if !(0..=MAX_4DIGIT_YEAR_SECS).contains(&secs) {
|
||||
return None;
|
||||
}
|
||||
let (_days, y, m, d, hh, mm, ss) = split(secs);
|
||||
push4(buf, 0, y);
|
||||
push2(buf, 4, m);
|
||||
push2(buf, 6, d);
|
||||
buf[8] = b'T';
|
||||
push2(buf, 9, hh);
|
||||
push2(buf, 11, mm);
|
||||
push2(buf, 13, ss);
|
||||
buf[15] = b'Z';
|
||||
// SAFETY-free: every byte written above is ASCII.
|
||||
Some(std::str::from_utf8(&buf[..]).expect("ascii"))
|
||||
}
|
||||
|
||||
/// `chrono::NaiveDate::format("%Y-%m-%d")` for a calendar date: the vCard
|
||||
/// `BDAY` / ISO date form `2026-07-17` (10 bytes) written into `buf`.
|
||||
///
|
||||
/// The vCard emit path (`contact_to_vcard`) stamps `BDAY` per
|
||||
/// contact-with-birthday, and `write!(…, "{}", date.format("%Y-%m-%d"))` runs
|
||||
/// chrono's strftime interpreter and heap-allocates — the same interpreter cost
|
||||
/// [`compact_ical_utc`] removed for the `REV` stamp (benches/ROUND19.md §V2:
|
||||
/// 3→0 allocs). This is the date-only companion to that helper.
|
||||
///
|
||||
/// Takes the pre-split `year`/`month`/`day` (so `fmt` stays chrono-free off the
|
||||
/// test path); callers read them via `chrono::Datelike`. Returns `None` when
|
||||
/// `year` is outside the fixed-width 4-digit range — where chrono widens or
|
||||
/// sign-prefixes `%Y` — so callers keep the chrono path as fallback.
|
||||
pub fn compact_date(buf: &mut [u8; 10], year: i32, month: u32, day: u32) -> Option<&str> {
|
||||
if !(0..=9999).contains(&year) {
|
||||
return None;
|
||||
}
|
||||
push4(buf, 0, year as i64);
|
||||
buf[4] = b'-';
|
||||
push2(buf, 5, month);
|
||||
buf[7] = b'-';
|
||||
push2(buf, 8, day);
|
||||
Some(std::str::from_utf8(&buf[..]).expect("ascii"))
|
||||
}
|
||||
|
||||
/// Append the upper-cased form of `s` to `buf` without a temporary `String`.
|
||||
///
|
||||
/// Byte-identical to `buf.push_str(&s.to_uppercase())` — same
|
||||
/// `char::to_uppercase` expansion (incl. ß → SS, ff → FF) — but writes straight
|
||||
/// into the caller's buffer. The vCard emit path (`contact_to_vcard`,
|
||||
/// `generate_vcard`) formats an `EMAIL`/`TEL`/`ADR` `TYPE=` token per line, and
|
||||
/// the old `write!(…, "{}", ty.to_uppercase())` heap-allocated one throw-away
|
||||
/// `String` per token per contact (benches/ROUND17.md §V1).
|
||||
pub fn push_upper(buf: &mut String, s: &str) {
|
||||
for c in s.chars() {
|
||||
for u in c.to_uppercase() {
|
||||
buf.push(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
/// `hex_lower` must match the `format!("{b:02x}")`-per-byte shape it
|
||||
/// replaced, byte for byte.
|
||||
#[test]
|
||||
fn hex_lower_matches_format() {
|
||||
let cases: [&[u8]; 5] = [
|
||||
&[],
|
||||
&[0x00],
|
||||
&[0xff, 0x00, 0xab],
|
||||
&(0u8..=255).collect::<Vec<u8>>(),
|
||||
b"The quick brown fox",
|
||||
];
|
||||
for bytes in cases {
|
||||
let reference: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(hex_lower(bytes), reference);
|
||||
}
|
||||
}
|
||||
|
||||
/// `push_upper` must match `push_str(&s.to_uppercase())` byte for byte,
|
||||
/// including multi-char upper-casings (ß → SS) and dotless-i.
|
||||
#[test]
|
||||
fn push_upper_matches_to_uppercase() {
|
||||
let cases = [
|
||||
"", "home", "WORK", "Cell", "voice", "x-custom", "café", "straße", "ff", "ı",
|
||||
];
|
||||
for s in cases {
|
||||
let mut got = String::new();
|
||||
push_upper(&mut got, s);
|
||||
assert_eq!(got, s.to_uppercase(), "push_upper differs for {s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge-heavy corpus: epoch, single-digit day (padding!), leap day,
|
||||
/// end-of-year, DST-irrelevant midsummer, far future, max in-range.
|
||||
const CASES: [i64; 12] = [
|
||||
0,
|
||||
1,
|
||||
86_399,
|
||||
86_400,
|
||||
951_782_400, // 2000-02-29 (leap)
|
||||
1_120_176_000, // 2005-07-01 (day < 10 → chrono pads)
|
||||
1_752_753_434,
|
||||
2_147_483_647,
|
||||
4_102_444_799, // 2099-12-31 23:59:59
|
||||
7_258_118_400,
|
||||
250_000_000_000,
|
||||
MAX_4DIGIT_YEAR_SECS,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn rfc3339_matches_chrono() {
|
||||
for &secs in &CASES {
|
||||
let dt = Utc.timestamp_opt(secs, 0).unwrap();
|
||||
let mut buf = [0u8; 25];
|
||||
assert_eq!(
|
||||
rfc3339_utc(&mut buf, secs).expect("in range"),
|
||||
dt.to_rfc3339(),
|
||||
"secs={secs}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc2822_matches_chrono() {
|
||||
for &secs in &CASES {
|
||||
let dt = Utc.timestamp_opt(secs, 0).unwrap();
|
||||
let mut buf = [0u8; 31];
|
||||
assert_eq!(
|
||||
rfc2822_utc(&mut buf, secs).expect("in range"),
|
||||
dt.to_rfc2822(),
|
||||
"secs={secs}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_ical_matches_chrono() {
|
||||
for &secs in &CASES {
|
||||
let dt = Utc.timestamp_opt(secs, 0).unwrap();
|
||||
let mut buf = [0u8; 16];
|
||||
assert_eq!(
|
||||
compact_ical_utc(&mut buf, secs).expect("in range"),
|
||||
dt.format("%Y%m%dT%H%M%SZ").to_string(),
|
||||
"secs={secs}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_date_matches_chrono() {
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
// Padding (day/month < 10), leap day, min/max in-range 4-digit year,
|
||||
// 3-digit year (chrono zero-pads %Y to 4).
|
||||
let cases = [
|
||||
(2026, 7, 17),
|
||||
(2000, 2, 29),
|
||||
(2005, 7, 1),
|
||||
(1970, 1, 1),
|
||||
(9999, 12, 31),
|
||||
(1, 1, 1),
|
||||
(876, 5, 9),
|
||||
];
|
||||
for (y, m, d) in cases {
|
||||
let date = NaiveDate::from_ymd_opt(y, m, d).unwrap();
|
||||
let mut buf = [0u8; 10];
|
||||
assert_eq!(
|
||||
compact_date(&mut buf, date.year(), date.month(), date.day()).expect("in range"),
|
||||
date.format("%Y-%m-%d").to_string(),
|
||||
"date={y}-{m}-{d}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_falls_back() {
|
||||
let mut b3 = [0u8; 25];
|
||||
let mut b2 = [0u8; 31];
|
||||
let mut bc = [0u8; 16];
|
||||
let mut bd = [0u8; 10];
|
||||
assert!(rfc3339_utc(&mut b3, -1).is_none());
|
||||
assert!(rfc2822_utc(&mut b2, -1).is_none());
|
||||
assert!(compact_ical_utc(&mut bc, -1).is_none());
|
||||
assert!(compact_date(&mut bd, -1, 1, 1).is_none());
|
||||
assert!(compact_date(&mut bd, 10000, 1, 1).is_none());
|
||||
assert!(rfc3339_utc(&mut b3, MAX_4DIGIT_YEAR_SECS + 1).is_none());
|
||||
assert!(compact_ical_utc(&mut bc, MAX_4DIGIT_YEAR_SECS + 1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ints_match_std() {
|
||||
let mut b = [0u8; 20];
|
||||
for v in [0u64, 1, 9, 10, 42, 1024, u64::MAX] {
|
||||
assert_eq!(u64_str(&mut b, v), v.to_string());
|
||||
}
|
||||
let mut b = [0u8; 21];
|
||||
for v in [0i64, -1, 42, -1024, i64::MIN, i64::MAX] {
|
||||
assert_eq!(i64_str(&mut b, v), v.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Exhaustive-ish sweep: every 6h13m across 60 years — catches any
|
||||
/// weekday / month-boundary drift against chrono.
|
||||
#[test]
|
||||
fn sweep_matches_chrono() {
|
||||
let mut secs: i64 = 0;
|
||||
while secs < 60 * 366 * 86_400 {
|
||||
let dt = Utc.timestamp_opt(secs, 0).unwrap();
|
||||
let mut b3 = [0u8; 25];
|
||||
let mut b2 = [0u8; 31];
|
||||
let mut bc = [0u8; 16];
|
||||
assert_eq!(rfc3339_utc(&mut b3, secs).unwrap(), dt.to_rfc3339());
|
||||
assert_eq!(rfc2822_utc(&mut b2, secs).unwrap(), dt.to_rfc2822());
|
||||
assert_eq!(
|
||||
compact_ical_utc(&mut bc, secs).unwrap(),
|
||||
dt.format("%Y%m%dT%H%M%SZ").to_string()
|
||||
);
|
||||
secs += 22_380; // 6h13m — walks through all times of day + weekdays
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,13 @@ pub struct LocaleRegistry {
|
||||
/// case-insensitive: input is canonicalised, then probed against
|
||||
/// this set.
|
||||
canonical: Arc<HashSet<SmolStr>>,
|
||||
/// The same codes as an owned `Vec<String>`, materialized ONCE at
|
||||
/// [`Self::discover`] time. The `Accept-Language` extractor needs a
|
||||
/// `&[&str]` supported-list per anonymous request; without this it
|
||||
/// rebuilt N heap `String`s from the registry on every such request
|
||||
/// (the ROUND10 §15 "process-invariant rebuilt per request" class;
|
||||
/// benches/ROUND13.md §L1). Borrowed via [`Self::supported_codes`].
|
||||
supported_codes: Arc<Vec<String>>,
|
||||
/// The configured fallback locale. Resolved from
|
||||
/// `OXICLOUD_DEFAULT_LOCALE` at startup; defaults to English when
|
||||
/// unset.
|
||||
@@ -200,8 +207,15 @@ impl LocaleRegistry {
|
||||
sorted.join(", ")
|
||||
);
|
||||
|
||||
// Materialize the supported-codes list once. Order is irrelevant —
|
||||
// `accept_language::intersection` ranks by the request header's
|
||||
// q-values, not by this list's order.
|
||||
let supported_codes: Vec<String> =
|
||||
canonical.iter().map(|s| s.as_str().to_string()).collect();
|
||||
|
||||
Ok(Self {
|
||||
canonical: Arc::new(canonical),
|
||||
supported_codes: Arc::new(supported_codes),
|
||||
default,
|
||||
})
|
||||
}
|
||||
@@ -236,6 +250,13 @@ impl LocaleRegistry {
|
||||
self.canonical.iter().map(|s| Locale(s.clone()))
|
||||
}
|
||||
|
||||
/// The registry's codes as a borrowable `&[String]`, precomputed at
|
||||
/// [`Self::discover`] time. Feeds the per-request `Accept-Language`
|
||||
/// negotiation without re-allocating the list (benches/ROUND13.md §L1).
|
||||
pub fn supported_codes(&self) -> &[String] {
|
||||
&self.supported_codes
|
||||
}
|
||||
|
||||
/// Number of locales in the registry. Used by tests + startup logs.
|
||||
pub fn len(&self) -> usize {
|
||||
self.canonical.len()
|
||||
|
||||
@@ -108,10 +108,117 @@ pub async fn refine_content_type_from_file(
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a MIME type identifies content that is already compressed, so
|
||||
/// running Deflate over it burns CPU for ~0 % size gain.
|
||||
///
|
||||
/// Used by the ZIP export paths (`ZipService`, `BatchOperations`) to pick
|
||||
/// `Compression::Stored` per entry instead of deflating JPEG/MP4/… bytes.
|
||||
/// The set mirrors the HTTP `CompressionLayer` exclusion list in `main.rs`
|
||||
/// (keep the two in sync), minus entries that are containers of possibly
|
||||
/// incompressible data rather than compressed formats themselves
|
||||
/// (`application/x-tar`, `application/octet-stream`) — those stay on Deflate
|
||||
/// so unknown-but-compressible content is never stored uncompressed.
|
||||
pub fn is_precompressed_mime(mime: &str) -> bool {
|
||||
// Strip any parameters ("; charset=…") and normalize case.
|
||||
let essence = mime.split(';').next().unwrap_or(mime).trim();
|
||||
|
||||
// Compressed families: every common video/audio codec container.
|
||||
if essence.starts_with("video/") || essence.starts_with("audio/") {
|
||||
return true;
|
||||
}
|
||||
// Zip-based document bundles (docx/xlsx/pptx, odt/ods/odp, …).
|
||||
if essence.starts_with("application/vnd.openxmlformats-officedocument")
|
||||
|| essence.starts_with("application/vnd.oasis.opendocument")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
matches!(
|
||||
essence,
|
||||
// Raster images with built-in compression (SVG intentionally absent).
|
||||
"image/jpeg"
|
||||
| "image/png"
|
||||
| "image/gif"
|
||||
| "image/webp"
|
||||
| "image/avif"
|
||||
| "image/heic"
|
||||
| "image/heif"
|
||||
| "image/jp2"
|
||||
// Already-compressed web fonts; ttf/otf left compressible.
|
||||
| "font/woff"
|
||||
| "font/woff2"
|
||||
| "application/font-woff"
|
||||
// Archives & compressed containers.
|
||||
| "application/zip"
|
||||
| "application/gzip"
|
||||
| "application/x-gzip"
|
||||
| "application/x-7z-compressed"
|
||||
| "application/x-rar-compressed"
|
||||
| "application/x-bzip2"
|
||||
| "application/zstd"
|
||||
| "application/x-xz"
|
||||
| "application/epub+zip"
|
||||
| "application/java-archive"
|
||||
| "application/vnd.android.package-archive"
|
||||
// PDF: internal streams are usually already deflated.
|
||||
| "application/pdf"
|
||||
)
|
||||
}
|
||||
|
||||
/// ZIP entry compression for a file of the given MIME type: `Stored` for
|
||||
/// already-compressed content, `Deflate` otherwise. Shared by every ZIP
|
||||
/// export path (`ZipService`, `BatchOperations`).
|
||||
pub fn zip_entry_compression(mime: &str) -> async_zip::Compression {
|
||||
if is_precompressed_mime(mime) {
|
||||
async_zip::Compression::Stored
|
||||
} else {
|
||||
async_zip::Compression::Deflate
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── is_precompressed_mime ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn media_and_archives_are_precompressed() {
|
||||
for mime in [
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"audio/mpeg",
|
||||
"application/zip",
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"font/woff2",
|
||||
] {
|
||||
assert!(is_precompressed_mime(mime), "{mime} should be Stored");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressible_types_keep_deflate() {
|
||||
for mime in [
|
||||
"text/plain",
|
||||
"text/html",
|
||||
"application/json",
|
||||
"image/svg+xml",
|
||||
"application/x-tar",
|
||||
"application/octet-stream",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_precompressed_mime(mime), "{mime} should stay Deflate");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mime_parameters_are_ignored() {
|
||||
assert!(is_precompressed_mime("image/jpeg; charset=binary"));
|
||||
}
|
||||
|
||||
// ── refine_content_type (sync) ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod config;
|
||||
pub mod di;
|
||||
pub mod errors;
|
||||
pub mod fmt;
|
||||
pub mod locale;
|
||||
pub mod mime_detect;
|
||||
pub mod runtime;
|
||||
pub mod stubs;
|
||||
pub mod text;
|
||||
|
||||
+20
-20
@@ -130,25 +130,12 @@ impl FileReadPort for StubFileReadPort {
|
||||
Ok((Vec::new(), 0))
|
||||
}
|
||||
|
||||
async fn count_files(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
_criteria: &SearchCriteriaDto,
|
||||
_user_id: Uuid,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn stream_files_in_subtree(
|
||||
&self,
|
||||
_folder_id: &str,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<File, DomainError>> + Send>>, DomainError> {
|
||||
Ok(Box::pin(futures::stream::empty()))
|
||||
}
|
||||
|
||||
async fn get_file_for_owner(&self, _id: &str, _owner_id: Uuid) -> Result<File, DomainError> {
|
||||
Ok(File::default())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -209,6 +196,7 @@ impl FileWritePort for StubFileWritePort {
|
||||
_size: u64,
|
||||
_modified_at: Option<i64>,
|
||||
_caller_id: Uuid,
|
||||
_expected_hash: Option<&str>,
|
||||
) -> Result<(String, i64), DomainError> {
|
||||
Ok((String::new(), 0))
|
||||
}
|
||||
@@ -274,10 +262,9 @@ impl FolderRepository for StubFolderStoragePort {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
async fn list_root_folders_for_caller(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -292,10 +279,9 @@ impl FolderRepository for StubFolderStoragePort {
|
||||
Ok((Vec::new(), Some(0)))
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner_paginated(
|
||||
async fn list_root_folders_for_caller_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
_offset: usize,
|
||||
_limit: usize,
|
||||
_include_total: bool,
|
||||
@@ -506,7 +492,8 @@ impl FileUploadUseCase for StubFileUploadUseCase {
|
||||
Ok(FileDto::default())
|
||||
}
|
||||
|
||||
async fn update_file_streaming(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn update_file_streaming_with_perms(
|
||||
&self,
|
||||
_path: &str,
|
||||
_drive_id: Uuid,
|
||||
@@ -514,6 +501,18 @@ impl FileUploadUseCase for StubFileUploadUseCase {
|
||||
_content_type: &str,
|
||||
_modified_at: Option<i64>,
|
||||
_caller_id: Uuid,
|
||||
_expected_hash: Option<&str>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
Ok(FileDto::default())
|
||||
}
|
||||
|
||||
async fn upload_file_streaming_with_perms(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_blob: StoredBlob,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
Ok(FileDto::default())
|
||||
}
|
||||
@@ -731,6 +730,7 @@ impl SearchUseCase for StubSearchUseCase {
|
||||
_query: &str,
|
||||
_folder_id: Option<&str>,
|
||||
_limit: usize,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<SearchSuggestionsDto, DomainError> {
|
||||
Ok(SearchSuggestionsDto {
|
||||
suggestions: Vec::new(),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Small allocation-free text predicates shared across the hot parse paths.
|
||||
|
||||
/// ASCII case-insensitive substring test — the allocation-free equivalent of
|
||||
/// `haystack_lower.contains(needle_lower)` when both are ASCII.
|
||||
///
|
||||
/// Callers pass an already-upper/lower-cased `needle` and get the same boolean
|
||||
/// `haystack.to_ascii_uppercase().contains(NEEDLE)` would, without the
|
||||
/// throwaway per-call `String`. Used by the search name-match classifier and by
|
||||
/// `ContactService::parse_vcard`'s per-line `TYPE=` routing
|
||||
/// (benches/ROUND20.md §A3).
|
||||
pub fn ascii_ci_contains(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
if needle.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if needle.len() > haystack.len() {
|
||||
return false;
|
||||
}
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.any(|w| w.eq_ignore_ascii_case(needle))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matches_uppercase_contains() {
|
||||
// Parity with the `to_ascii_uppercase().contains(NEEDLE)` shape it
|
||||
// replaced, across mixed case and the empty/oversize edge cases.
|
||||
let cases: &[(&str, &str)] = &[
|
||||
("EMAIL;TYPE=home:a@b.com", "TYPE=HOME"),
|
||||
("EMAIL;type=Work:a@b.com", "TYPE=WORK"),
|
||||
("TEL;TYPE=CELL:+1", "TYPE=CELL"),
|
||||
("TEL;TYPE=voice:+1", "TYPE=CELL"),
|
||||
("ADR;TYPE=Home:;;x", "TYPE=WORK"),
|
||||
("", "TYPE=HOME"),
|
||||
("short", "a-very-long-needle"),
|
||||
];
|
||||
for (hay, needle) in cases {
|
||||
let reference = hay.to_ascii_uppercase().contains(needle);
|
||||
assert_eq!(
|
||||
ascii_ci_contains(hay.as_bytes(), needle.as_bytes()),
|
||||
reference,
|
||||
"mismatch for haystack={hay:?} needle={needle:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_needle_is_true() {
|
||||
assert!(ascii_ci_contains(b"anything", b""));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user