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:
Bradley Nelson
2026-07-21 17:09:36 -06:00
600 changed files with 105575 additions and 14440 deletions
+42
View File
@@ -49,6 +49,48 @@ pub struct Calendar {
custom_properties: std::collections::HashMap<String, String>,
}
/// Owned decomposition of a [`Calendar`] (mirrors `FileParts`/`UserParts`).
/// Lets `CalendarDto::from` MOVE the heap fields — notably the
/// `custom_properties` map — instead of cloning them on every CalDAV discovery
/// listing (benches/ROUND20.md §A4).
pub struct CalendarParts {
pub id: Uuid,
pub name: String,
pub owner_id: Uuid,
pub description: Option<String>,
pub color: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub custom_properties: std::collections::HashMap<String, String>,
}
impl Calendar {
/// Decompose into [`CalendarParts`], moving every owned field out
/// (exhaustive destructure — compiler-checked against added fields).
pub fn into_parts(self) -> CalendarParts {
let Calendar {
id,
name,
owner_id,
description,
color,
created_at,
updated_at,
custom_properties,
} = self;
CalendarParts {
id,
name,
owner_id,
description,
color,
created_at,
updated_at,
custom_properties,
}
}
}
impl Calendar {
/**
* Creates a new calendar with the given properties.
File diff suppressed because it is too large Load Diff
+48
View File
@@ -13,6 +13,48 @@ pub struct AddressBook {
updated_at: DateTime<Utc>,
}
/// Owned decomposition of an [`AddressBook`] (mirrors `FileParts`/`UserParts`).
/// Lets `AddressBookDto::from` MOVE `name`/`description`/`color`/`owner_id`
/// instead of cloning them on every CardDAV discovery listing
/// (benches/ROUND20.md §A4).
pub struct AddressBookParts {
pub id: Uuid,
pub name: String,
pub owner_id: String,
pub description: Option<String>,
pub color: Option<String>,
pub is_public: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl AddressBook {
/// Decompose into [`AddressBookParts`], moving every owned field out
/// (exhaustive destructure — compiler-checked against added fields).
pub fn into_parts(self) -> AddressBookParts {
let AddressBook {
id,
name,
owner_id,
description,
color,
is_public,
created_at,
updated_at,
} = self;
AddressBookParts {
id,
name,
owner_id,
description,
color,
is_public,
created_at,
updated_at,
}
}
}
impl AddressBook {
/// Creates a new AddressBook with generated id and timestamps
pub fn new(
@@ -402,6 +444,9 @@ impl Contact {
pub fn push_phone(&mut self, p: Phone) {
self.phone.push(p);
}
pub fn push_address(&mut self, a: Address) {
self.address.push(a);
}
pub fn set_email(&mut self, email: Vec<Email>) {
self.email = email;
}
@@ -417,6 +462,9 @@ impl Contact {
pub fn phone_is_empty(&self) -> bool {
self.phone.is_empty()
}
pub fn address_is_empty(&self) -> bool {
self.address.is_empty()
}
// --- Consuming methods for ownership transfer ---
pub fn into_email(self) -> Vec<Email> {
+381
View File
@@ -43,6 +43,9 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::Subject;
/// Drive kind discriminant. Mirrors the `storage.drives.kind` CHECK
/// constraint values.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -128,9 +131,387 @@ impl Drive {
self.default_for_user == Some(user_id)
}
/// Typed view of `policies` for enforcement code. Lenient deserialise:
/// unknown keys are preserved on disk (the column stays the canonical
/// JSONB bag) but ignored here, missing keys default to `false`.
/// See `docs/plan/drive.md` §8.
pub fn typed_policies(&self) -> DrivePolicies {
DrivePolicies::from_value(&self.policies)
}
/// `true` if this drive is a personal drive of any kind (default or
/// secondary). Encapsulates the kind check at the call site.
pub fn is_personal(&self) -> bool {
matches!(self.kind, DriveKind::Personal)
}
}
/// Typed mirror of the `policies` JSONB. Five known keys; the JSONB column
/// remains the source of truth and may carry unknown keys verbatim — this
/// struct is a read view for enforcement and a write view for the policy
/// PATCH endpoint. Every field defaults to `false` (everything allowed)
/// so a freshly-created drive doesn't need a populated policy bag.
///
/// See `docs/plan/drive.md` §8 for the enforcement matrix
/// (which callsite each key is checked at).
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default)]
pub struct DrivePolicies {
/// Disables per-resource grants on resources in this drive. Drive-level
/// membership (Owner/Editor/Viewer) still works. Enforced at
/// `grant_handler::create_grant`.
pub forbid_sharing: bool,
/// Blocks grants whose subject has `users.is_external = true`. Enforced
/// at `magic_link_invite_service::resolve_or_create_recipient` and
/// `grant_handler::create_grant`.
pub forbid_external_sharing: bool,
/// Blocks anonymous-link (token-share) creation on resources in this
/// drive. Enforced at `share_service::create_shared_link`.
pub forbid_public_links: bool,
/// Blocks MOVE when `src.drive_id != dst.drive_id`. Enforced at the
/// move endpoints. Lands paired with D6's cross-drive move work.
pub forbid_cross_drive_move: bool,
/// Locks the Owner-role membership set: no owner can be added,
/// removed, or demoted by another owner — only OxiCloud admin can
/// change the Owner roster. Editor / Viewer mutations by remaining
/// owners are unaffected. Personal drives are already
/// single-owner-immutable via `refuse_if_personal`, so this policy
/// only adds value on shared drives. Enforced at
/// `DriveManagementService::set_member_role` (refuses Owner role
/// writes) and `::remove_member` (refuses Owner removals) when the
/// caller is non-admin.
pub forbid_owner_role_change: bool,
/// Opts this drive into the `/api/photos` timeline (§15). Non-default
/// drives are omitted by default so a random shared folder full of
/// screenshots doesn't bleed into the personal timeline; owners flip
/// this on when the drive genuinely is a photo library (e.g. "Family
/// Photos"). Default personal drives get `true` on creation via the
/// `PersonalDriveLifecycleHook` + a one-shot backfill for existing
/// rows, so the SQL predicate is a single positive rule with no
/// per-kind carve-out. Read at `file_blob_read_repository::
/// list_media_files` + `list_geo_clusters`. See §15 for the query
/// shape and rationale.
pub include_in_photo_index: bool,
/// Same shape as `include_in_photo_index`, applied to the Music
/// library surface (playlists today; a `/api/music/tracks` library
/// view later). Symmetric opt-in — Music was originally cross-drive
/// via a `forbid_music_index` opt-out, but that mixed-form naming
/// created "one include-in, one forbid" confusion and the
/// "shared audio is always intentional" claim didn't hold under
/// scrutiny (voicemail MP3s in a work drive shouldn't bleed into
/// the personal library). See §15.
pub include_in_music_index: bool,
/// **Full freeze / legal-hold.** When `true`, every mutation on
/// resources in this drive is refused — user-initiated and
/// background alike. Compliance-grade guarantee:
///
/// - User-initiated: enforced at `PgAclEngine::check_inner`, which
/// short-circuits `Create` / `Update` / `Delete` / `Share`
/// permissions on any resource in a read-only drive. Read still
/// passes. Manage-on-Drive still passes so admins can un-freeze.
/// - Background jobs: the periodic trash-retention purge and
/// orphan-upload sweep filter out read-only drives at SELECT
/// time (SQL-side `JOIN storage.drives … WHERE (policies->>
/// 'read_only')::boolean IS NOT TRUE`). Retention clock keeps
/// ticking; on unfreeze, the next sweep tick catches up.
///
/// Applies to both personal and shared drives — a user winding
/// down their account, freezing a secondary personal archive, or
/// putting a shared drive on legal hold all use the same knob.
/// Mutation is admin-only via `PATCH /api/drives/{id}/policies`
/// (per §8 — same carve-out as every other policy).
pub read_only: bool,
}
impl DrivePolicies {
/// Parse from the raw JSONB. Lenient — unknown keys are dropped from
/// the typed view but remain in the source `serde_json::Value`. A
/// malformed bag (e.g. wrong type) falls back to the all-false default
/// rather than refusing the read; enforcement code never panics on
/// existing data.
pub fn from_value(value: &serde_json::Value) -> Self {
// Deserialize straight from the borrowed `Value` (`T::deserialize(&Value)`,
// via serde_json's `Deserializer for &Value`) instead of
// `serde_json::from_value(value.clone())` — the old form cloned the ENTIRE
// policies DOM before walking it, on every drive-policy read (move/copy,
// shared-link creation, grant). Byte-identical (same derived `Deserialize`
// impl); the lenient `unwrap_or_default` fallback is unchanged.
// (benches/ROUND23.md §J2)
use serde::Deserialize as _;
Self::deserialize(value).unwrap_or_default()
}
/// D5 `forbid_public_links` gate, used by every entry point that
/// mints an anonymous token-share on a resource in this drive
/// (`share_service::create_shared_link` today; future protocol
/// surfaces — e.g. NextCloud OCS share — must call this too). The
/// gate owns the decision + audit + canonical error so the
/// rejection shape stays in lockstep across surfaces. See
/// `docs/plan/drive.md` §8.
///
/// Returns `Ok(())` when the policy is off; emits a
/// `share.rejected` audit line and returns
/// `OperationNotSupported` when on.
pub fn refuse_public_links(&self, ctx: PublicLinkGateContext) -> Result<(), DomainError> {
if !self.forbid_public_links {
return Ok(());
}
tracing::info!(
target: "audit",
event = "share.rejected",
reason = "forbid_public_links",
caller_id = %ctx.caller_id,
item_type = ctx.item_type,
item_id = %ctx.item_id,
"👮🏻‍♂️ public-link creation refused: forbid_public_links",
);
Err(DomainError::operation_not_supported(
"Share",
"This drive does not allow public links.",
))
}
/// D5 `forbid_sharing` gate: refuses **per-resource** grants on
/// resources in this drive when the policy is on. Drive-level
/// membership stays unaffected — otherwise a drive that disables
/// sharing would also become uneditable except by the original
/// owner. The semantic the plan §8 commits to is "no fine-grained
/// sharing of individual files; access happens through drive
/// membership only."
///
/// Enforced at `grant_handler::create_grant` for File / Folder
/// resources. The Drive-resource branch of `/api/grants` and the
/// `/api/drives/{id}/members` routes deliberately don't call this
/// gate.
///
/// Returns `Ok(())` when the policy is off; emits a
/// `grant.rejected` audit line and returns `OperationNotSupported`
/// when on.
pub fn refuse_sharing(&self, ctx: SharingGateContext) -> Result<(), DomainError> {
if !self.forbid_sharing {
return Ok(());
}
tracing::info!(
target: "audit",
event = "grant.rejected",
reason = "forbid_sharing",
caller_id = %ctx.caller_id,
resource_type = ctx.resource_type,
resource_id = %ctx.resource_id,
"👮🏻‍♂️ per-resource grant refused: forbid_sharing",
);
Err(DomainError::operation_not_supported(
"Grant",
"This drive does not allow per-resource sharing.",
))
}
/// D5 `forbid_owner_role_change` gate: refuses Owner-role mutations
/// (adding a new Owner, demoting an existing Owner, or removing
/// one) when the caller isn't OxiCloud admin and the policy is on.
/// Membership of non-Owner roles is unaffected.
///
/// Enforced at `DriveManagementService::set_member_role` (refuses
/// Owner role writes) and `::remove_member` (refuses removing an
/// Owner subject). Skipped when `caller_is_admin = true` — the
/// policy exists to constrain owners, not the tenant operator.
/// Personal drives never reach this gate because
/// `refuse_if_personal` rejects every member mutation upstream.
///
/// Returns `Ok(())` when the policy is off or the caller is admin;
/// emits a `drive_membership.rejected` audit line and returns
/// `OperationNotSupported` otherwise.
pub fn refuse_owner_role_change(
&self,
ctx: OwnerRoleChangeGateContext,
) -> Result<(), DomainError> {
if !self.forbid_owner_role_change {
return Ok(());
}
if ctx.caller_is_admin {
return Ok(());
}
tracing::info!(
target: "audit",
event = "drive_membership.rejected",
reason = "forbid_owner_role_change",
operation = ctx.operation,
caller_id = %ctx.caller_id,
drive_id = %ctx.drive_id,
subject_type = ctx.subject_type,
subject_id = %ctx.subject_id,
"👮🏻‍♂️ owner-role mutation refused: forbid_owner_role_change",
);
Err(DomainError::operation_not_supported(
"Drive",
"This drive's Owner membership is locked — only OxiCloud admin can change owners.",
))
}
/// D5 `forbid_cross_drive_move` gate: refuses MOVE when
/// `src.drive_id != dst.drive_id`. The policy lives on the SOURCE
/// drive — its owner decides whether content can leave. Targets'
/// owners already gate inbound moves via the `Create` permission
/// on the destination folder, so a symmetric check would be
/// redundant.
///
/// Enforced at `file_management_service::move_file_with_perms`
/// and `folder_service::move_folder_with_perms`. The handler
/// doesn't see this gate — it lives in the service layer per
/// the AuthZ architecture rule in CLAUDE.md.
///
/// Returns `Ok(())` when the policy is off; emits a
/// `move.rejected` audit line and returns `OperationNotSupported`
/// when on.
pub fn refuse_cross_drive_move(
&self,
ctx: CrossDriveMoveGateContext,
) -> Result<(), DomainError> {
if !self.forbid_cross_drive_move {
return Ok(());
}
tracing::info!(
target: "audit",
event = "move.rejected",
reason = "forbid_cross_drive_move",
caller_id = %ctx.caller_id,
resource_type = ctx.resource_type,
resource_id = %ctx.resource_id,
src_drive_id = %ctx.src_drive_id,
dst_drive_id = %ctx.dst_drive_id,
"👮🏻‍♂️ cross-drive move refused: forbid_cross_drive_move",
);
Err(DomainError::operation_not_supported(
"Move",
"This drive does not allow moving content out to another drive.",
))
}
/// D5 `forbid_external_sharing` gate, shared by every entry point
/// that creates a grant on a resource in this drive
/// (`grant_handler::create_grant`,
/// `DriveManagementService::set_member_role`). Each caller
/// resolves `is_external` from whichever source naturally fits
/// (the just-created `User` entity in the email path, a
/// `get_user_flags` probe in the user-by-id path); the gate
/// itself owns the decision + audit + canonical error so the
/// shape stays in lockstep across surfaces. See `docs/plan/drive.md` §8.
///
/// Returns `Ok(())` when the subject is allowed (policy off, subject
/// is not a User, or the User is not external). Returns
/// `OperationNotSupported` after emitting a `grant.rejected` audit
/// line otherwise.
pub fn refuse_external_sharing(
&self,
subject: Subject,
is_external: bool,
ctx: ExternalSharingGateContext,
) -> Result<(), DomainError> {
if !self.forbid_external_sharing {
return Ok(());
}
let Subject::User(uid) = subject else {
return Ok(());
};
if !is_external {
return Ok(());
}
tracing::info!(
target: "audit",
event = "grant.rejected",
reason = "forbid_external_sharing",
stage = ctx.stage,
caller_id = %ctx.caller_id,
subject_id = %uid,
drive_id = ?ctx.drive_id,
resource_type = ?ctx.resource_type,
resource_id = ?ctx.resource_id,
"👮🏻‍♂️ grant refused: forbid_external_sharing",
);
Err(DomainError::operation_not_supported(
"Grant",
"This drive does not allow external sharing.",
))
}
}
/// Audit / identity context for [`DrivePolicies::refuse_owner_role_change`].
///
/// Carries the subject (the user/group whose Owner status is being
/// added, removed, or demoted) and the calling operation tag
/// (`"set_member_role"` or `"remove_member"`) so the audit log
/// pinpoints exactly which mutation the policy refused.
#[derive(Debug, Clone, Copy)]
pub struct OwnerRoleChangeGateContext {
pub caller_id: Uuid,
pub caller_is_admin: bool,
pub drive_id: Uuid,
pub operation: &'static str,
pub subject_type: &'static str,
pub subject_id: Uuid,
}
/// Audit / identity context for [`DrivePolicies::refuse_cross_drive_move`].
///
/// Carries the source and destination drive ids so the audit log
/// captures exactly which boundary the refused move would cross —
/// useful when investigating whether someone is probing the gate or
/// genuinely trying to organize content.
#[derive(Debug, Clone, Copy)]
pub struct CrossDriveMoveGateContext {
pub caller_id: Uuid,
/// `"file"` or `"folder"`.
pub resource_type: &'static str,
pub resource_id: Uuid,
pub src_drive_id: Uuid,
pub dst_drive_id: Uuid,
}
/// Audit / identity context for [`DrivePolicies::refuse_sharing`].
///
/// Only File / Folder resources reach this gate — the per-resource
/// grant surface. Drive-resource grants go through
/// `set_member_role` and aren't subject to `forbid_sharing`.
#[derive(Debug, Clone, Copy)]
pub struct SharingGateContext {
pub caller_id: Uuid,
/// `"file"` or `"folder"`.
pub resource_type: &'static str,
pub resource_id: Uuid,
}
/// Audit / identity context for [`DrivePolicies::refuse_public_links`].
///
/// Single callsite today (`share_service::create_shared_link`), but the
/// struct is the explicit contract so future surfaces (NextCloud OCS
/// share, WebDAV public-link sigil, …) land with the same shape.
#[derive(Debug, Clone, Copy)]
pub struct PublicLinkGateContext {
pub caller_id: Uuid,
/// `"file"` or `"folder"` — the share target's resource kind.
pub item_type: &'static str,
pub item_id: Uuid,
}
/// Audit / identity context for [`DrivePolicies::refuse_external_sharing`].
///
/// Two callsites with different identifiers naturally fill this in:
/// - `grant_handler` (File/Folder branch): `drive_id = None`,
/// `resource_type` + `resource_id` set
/// - `DriveManagementService::set_member_role`: `drive_id` set,
/// `resource_type` + `resource_id = None`
///
/// All three appear in the audit log so a single grep on
/// `grant.rejected reason=forbid_external_sharing` surfaces every
/// refusal regardless of entry point.
#[derive(Debug, Clone, Copy)]
pub struct ExternalSharingGateContext {
pub caller_id: Uuid,
/// Distinguishes the call site for log aggregators. Known values
/// today: `"late_user"` (grant_handler), `"drive_member"`
/// (set_member_role). New entry points pick a fresh string.
pub stage: &'static str,
pub drive_id: Option<Uuid>,
pub resource_type: Option<&'static str>,
pub resource_id: Option<Uuid>,
}
+4
View File
@@ -79,6 +79,9 @@ pub enum UserError {
ValidationError(String),
/// Authentication error
AuthenticationError(String),
/// Upgrade path: the user is already internal — cannot re-upgrade.
/// Surfaced by the service as `error_type = "AlreadyInternal"`.
AlreadyInternal,
}
impl Display for UserError {
@@ -88,6 +91,7 @@ impl Display for UserError {
UserError::InvalidPassword(msg) => write!(f, "Invalid password: {}", msg),
UserError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
UserError::AuthenticationError(msg) => write!(f, "Authentication error: {}", msg),
UserError::AlreadyInternal => write!(f, "User is already an internal account"),
}
}
}
+13
View File
@@ -32,6 +32,19 @@ impl BoundingBox {
}
}
/// A face box for the lightbox tagging overlay — the narrow projection of a
/// persisted [`Face`] that the People API's `faces_for_file` needs (`id`,
/// `person_id`, `bbox`). Fetching this instead of a full [`Face`] keeps the
/// 2 KiB `embedding` BYTEA (plus det_score/quality/blob_hash/created_at) off
/// the wire on every lightbox open of a face-tagged photo. See
/// benches/ROUND14.md §Q1.
#[derive(Debug, Clone)]
pub struct FaceBox {
pub id: Uuid,
pub person_id: Option<Uuid>,
pub bbox: BoundingBox,
}
/// A face produced by the analyzer but not yet persisted: where it is, how
/// confident the detector was, an optional quality score, and a 512-d,
/// L2-normalized embedding.
+82 -55
View File
@@ -1,7 +1,7 @@
use uuid::Uuid;
use crate::domain::services::path_service::{
StoragePath, normalize_storage_name, validate_storage_name,
StoragePath, normalize_storage_name_owned, validate_storage_name,
};
// Re-export entity errors from the centralized module
@@ -16,13 +16,11 @@ pub struct FileParts {
pub id: String,
pub name: String,
pub storage_path: StoragePath,
pub path_string: String,
pub size: u64,
pub mime_type: String,
pub folder_id: Option<String>,
pub created_at: u64,
pub modified_at: u64,
pub owner_id: Option<Uuid>,
/// BLAKE3 content hash. See [`File::content_hash`] for semantics.
pub blob_hash: String,
/// §14 provenance: original creator. See [`File::created_by`].
@@ -49,12 +47,11 @@ pub struct File {
/// Name of the file including extension
name: String,
/// Path to the file in the domain model
/// Path to the file in the domain model. Owns the canonical joined
/// string; `path_string()` borrows it (the separate duplicate field
/// was removed in ROUND11 §20 along with per-segment allocations).
storage_path: StoragePath,
/// String representation of the path for API compatibility
path_string: String,
/// Size of the file in bytes
size: u64,
@@ -70,9 +67,6 @@ pub struct File {
/// Last modification timestamp (seconds since UNIX epoch)
modified_at: u64,
/// Owner user ID (from storage.files.user_id)
owner_id: Option<Uuid>,
/// BLAKE3 content hash. Stable across renames/moves, changes only
/// when the file's content bytes change. Source of truth for both
/// content-addressable storage and the HTTP ETag (via
@@ -103,13 +97,11 @@ impl Default for File {
id: "stub-id".to_string(),
name: "stub-file.txt".to_string(),
storage_path: StoragePath::from_string("/"),
path_string: "/".to_string(),
size: 0,
mime_type: "application/octet-stream".to_string(),
folder_id: None,
created_at: 0,
modified_at: 0,
owner_id: None,
blob_hash: String::new(),
created_by: None,
updated_by: None,
@@ -127,7 +119,7 @@ impl File {
mime_type: String,
folder_id: Option<String>,
) -> FileResult<Self> {
let name = normalize_storage_name(&name);
let name = normalize_storage_name_owned(name);
if let Err(reason) = validate_storage_name(&name) {
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
}
@@ -137,20 +129,15 @@ impl File {
.unwrap_or_default()
.as_secs();
// Store the path string for serialization compatibility
let path_string = storage_path.to_string();
Ok(Self {
id,
name,
storage_path,
path_string,
size,
mime_type,
folder_id,
created_at: now,
modified_at: now,
owner_id: None,
blob_hash: String::new(),
created_by: None,
updated_by: None,
@@ -166,25 +153,20 @@ impl File {
created_at: u64,
modified_at: u64,
) -> FileResult<Self> {
let name = normalize_storage_name(&name);
let name = normalize_storage_name_owned(name);
if let Err(reason) = validate_storage_name(&name) {
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
}
// Store the path string for serialization compatibility
let path_string = storage_path.to_string();
Ok(Self {
id,
name,
storage_path,
path_string,
size: 0, // Folders have zero size
mime_type: "directory".to_string(), // Standard MIME type for directories
folder_id: parent_id,
created_at,
modified_at,
owner_id: None,
blob_hash: String::new(),
created_by: None,
updated_by: None,
@@ -201,7 +183,6 @@ impl File {
folder_id: Option<String>,
created_at: u64,
modified_at: u64,
owner_id: Option<Uuid>,
) -> FileResult<Self> {
Self::with_timestamps_and_blob_hash(
id,
@@ -212,7 +193,6 @@ impl File {
folder_id,
created_at,
modified_at,
owner_id,
String::new(),
)
}
@@ -227,7 +207,6 @@ impl File {
folder_id: Option<String>,
created_at: u64,
modified_at: u64,
owner_id: Option<Uuid>,
blob_hash: String,
) -> FileResult<Self> {
Self::with_timestamps_blob_hash_and_provenance(
@@ -239,7 +218,6 @@ impl File {
folder_id,
created_at,
modified_at,
owner_id,
blob_hash,
None,
None,
@@ -259,30 +237,74 @@ impl File {
folder_id: Option<String>,
created_at: u64,
modified_at: u64,
owner_id: Option<Uuid>,
blob_hash: String,
created_by: Option<Uuid>,
updated_by: Option<Uuid>,
) -> FileResult<Self> {
let name = normalize_storage_name(&name);
let name = normalize_storage_name_owned(name);
if let Err(reason) = validate_storage_name(&name) {
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
}
Ok(Self {
id,
name,
storage_path,
size,
mime_type,
folder_id,
created_at,
modified_at,
blob_hash,
created_by,
updated_by,
})
}
/// PG-row constructor: the per-listing-row hot path.
///
/// Builds `storage_path` **and** `path_string` in one pass from the
/// materialized folder path via
/// [`StoragePath::from_folder_and_name`], instead of the old chain
/// (`format!` temp → `from_string` split → `Display` re-join) that
/// allocated the full path three times per row. The owned `name` is
/// NFC-normalized without the always-copy of the borrowing variant
/// (DB rows are NFC by invariant, so this is a zero-alloc check).
///
/// The path is built from the raw incoming name and the name field is
/// normalized afterwards — the exact observable sequence of the old
/// `make_file_path` + constructor pair, byte-identical for every
/// input (for DB rows the two names coincide: stored names are NFC).
#[allow(clippy::too_many_arguments)]
pub fn from_materialized_row(
id: String,
name: String,
folder_path: Option<&str>,
size: u64,
mime_type: String,
folder_id: Option<String>,
created_at: u64,
modified_at: u64,
blob_hash: String,
created_by: Option<Uuid>,
updated_by: Option<Uuid>,
) -> FileResult<Self> {
let storage_path = StoragePath::from_folder_and_name(folder_path, &name);
let name = normalize_storage_name_owned(name);
if let Err(reason) = validate_storage_name(&name) {
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
}
// Store the path string for serialization compatibility
let path_string = storage_path.to_string();
Ok(Self {
id,
name,
storage_path,
path_string,
size,
mime_type,
folder_id,
created_at,
modified_at,
owner_id,
blob_hash,
created_by,
updated_by,
@@ -298,13 +320,11 @@ impl File {
id: self.id,
name: self.name,
storage_path: self.storage_path,
path_string: self.path_string,
size: self.size,
mime_type: self.mime_type,
folder_id: self.folder_id,
created_at: self.created_at,
modified_at: self.modified_at,
owner_id: self.owner_id,
blob_hash: self.blob_hash,
created_by: self.created_by,
updated_by: self.updated_by,
@@ -366,8 +386,25 @@ impl File {
/// formula here changes it everywhere — that is the property
/// we want.
pub fn compute_etag(blob_hash: &str, modified_at: u64) -> String {
let prefix: String = blob_hash.chars().take(16).collect();
format!("{}-{}", prefix, modified_at)
use std::fmt::Write as _;
// Byte index just past the 16th char (whole string when shorter).
// `blob_hash` is lowercase hex ASCII in practice, so this is
// effectively `min(len, 16)`, but `char_indices` keeps the slice
// char-boundary-safe for exotic fixture values — byte-identical
// to the old `chars().take(16).collect::<String>()` without the
// intermediate allocation.
let end = match blob_hash.char_indices().nth(16) {
Some((i, _)) => i,
None => blob_hash.len(),
};
// Single allocation: prefix + '-' + up to 20 digits (u64::MAX).
let mut etag = String::with_capacity(end + 1 + 20);
etag.push_str(&blob_hash[..end]);
etag.push('-');
let _ = write!(etag, "{modified_at}");
etag
}
// Getters
@@ -384,7 +421,7 @@ impl File {
}
pub fn path_string(&self) -> &str {
&self.path_string
self.storage_path.as_str()
}
pub fn size(&self) -> u64 {
@@ -407,10 +444,6 @@ impl File {
self.modified_at
}
pub fn owner_id(&self) -> Option<Uuid> {
self.owner_id
}
/// User that originally created this file (§14 provenance).
/// `None` when the referenced user has been deleted
/// (FK is `ON DELETE SET NULL`) or for stub/DTO entities.
@@ -437,25 +470,24 @@ impl File {
created_at: u64,
modified_at: u64,
) -> Self {
// Create storage_path from string
let storage_path = StoragePath::from_string(&path);
// Adopt the DTO path (canonical inputs are reused with zero
// copies; non-canonical ones are normalized like from_string did).
let storage_path = StoragePath::from_joined(path);
// Create directly without validation to avoid errors in DTO
// conversions. Still NFC-normalize so even DTO-reconstructed
// entities maintain the storage invariant.
let name = normalize_storage_name(&name);
let name = normalize_storage_name_owned(name);
Self {
id,
name,
storage_path,
path_string: path,
size,
mime_type,
folder_id,
created_at,
modified_at,
owner_id: None,
blob_hash: String::new(),
// DTO round-trips don't carry provenance; callers needing
// it must reload from the repository.
@@ -468,7 +500,7 @@ impl File {
/// Creates a new version of the file with updated name
pub fn with_name(mut self, new_name: String) -> FileResult<Self> {
let new_name = normalize_storage_name(&new_name);
let new_name = normalize_storage_name_owned(new_name);
if let Err(reason) = validate_storage_name(&new_name) {
return Err(FileError::InvalidFileName(format!("{new_name}: {reason}")));
}
@@ -487,7 +519,6 @@ impl File {
// Consume `self` and mutate in place — only the path, name and mtime
// change; id / mime_type / folder_id / blob_hash are carried over
// without the per-field clone the old `&self` builder paid.
self.path_string = new_storage_path.to_string();
self.storage_path = new_storage_path;
self.name = new_name;
self.modified_at = now;
@@ -512,7 +543,6 @@ impl File {
.as_secs();
// Consume `self`: only the path, folder_id and mtime change.
self.path_string = new_storage_path.to_string();
self.storage_path = new_storage_path;
self.folder_id = folder_id;
self.modified_at = now;
@@ -607,7 +637,6 @@ mod tests {
None,
1_000,
2_000,
None,
"abcdef0123456789ZZZZZZZZ".to_string(),
)
.unwrap();
@@ -632,7 +661,6 @@ mod tests {
None,
1_000,
2_000,
None,
"shorthash".to_string(),
)
.unwrap();
@@ -655,7 +683,6 @@ mod tests {
None,
1_000,
2_000,
None,
"stable-content-hash".to_string(),
)
.unwrap();
+124 -106
View File
@@ -1,12 +1,35 @@
use uuid::Uuid;
use crate::domain::services::path_service::{
StoragePath, normalize_storage_name, validate_storage_name,
StoragePath, normalize_storage_name_owned, validate_storage_name,
};
// Re-export entity errors from the centralized module
pub use super::entity_errors::{FolderError, FolderResult};
/// Owned parts of a [`Folder`] entity, produced by [`Folder::into_parts()`].
///
/// Consuming a `Folder` into `FolderParts` **moves** every field without
/// cloning, eliminating the 3-4 heap allocations that previously occurred
/// when converting `Folder → FolderDto` via `.to_string()` on each getter.
/// Mirrors [`super::file::FileParts`].
pub struct FolderParts {
pub id: String,
pub name: String,
pub storage_path: StoragePath,
pub parent_id: Option<String>,
/// Drive that owns this folder. See [`Folder::drive_id`].
pub drive_id: Uuid,
pub created_at: u64,
pub modified_at: u64,
/// Descendant-rollup timestamp. See [`Folder::tree_modified_at`].
pub tree_modified_at: u64,
/// §14 provenance: original creator. See [`Folder::created_by`].
pub created_by: Option<Uuid>,
/// §14 provenance: most recent mutator. See [`Folder::updated_by`].
pub updated_by: Option<Uuid>,
}
/// Represents a folder entity in the domain
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Folder {
@@ -16,19 +39,13 @@ pub struct Folder {
/// Name of the folder
name: String,
/// Path to the folder in the domain model
/// Path to the folder in the domain model. Owns the canonical joined
/// string; `path_string()` borrows it (ROUND11 §20).
storage_path: StoragePath,
/// String representation of the path (for API compatibility)
path_string: String,
/// Parent folder ID (None if it's a root folder)
parent_id: Option<String>,
/// Owner user ID — scopes folder visibility per user.
/// `None` only for legacy/stub folders; real folders always have an owner.
owner_id: Option<Uuid>,
/// Drive that owns this folder. Post-D0 every `storage.folders` row
/// has `drive_id NOT NULL` (M3 migration). Path-based lookups scope
/// by this axis (not by `user_id`, which is dropped in D7).
@@ -74,9 +91,7 @@ impl Default for Folder {
id: "stub-id".to_string(),
name: "stub-folder".to_string(),
storage_path: StoragePath::from_string("/"),
path_string: "/".to_string(),
parent_id: None,
owner_id: None,
drive_id: Uuid::nil(),
created_at: 0,
modified_at: 0,
@@ -88,26 +103,20 @@ impl Default for Folder {
}
impl Folder {
/// Creates a new folder with validation
/// Creates a new folder with validation.
///
/// In-memory constructor: callers that don't supply a `drive_id`
/// are by definition stub/legacy paths (tests, pre-D0 fixtures,
/// DTO round-trips). Real DB-backed folders flow through
/// [`Folder::with_timestamps_and_tree`] which propagates the
/// drive scope and §14 provenance from the row.
pub fn new(
id: String,
name: String,
storage_path: StoragePath,
parent_id: Option<String>,
) -> FolderResult<Self> {
Self::new_with_owner(id, name, storage_path, parent_id, None)
}
/// Creates a new folder with validation and an explicit owner.
pub fn new_with_owner(
id: String,
name: String,
storage_path: StoragePath,
parent_id: Option<String>,
owner_id: Option<Uuid>,
) -> FolderResult<Self> {
let name = normalize_storage_name(&name);
// Validate folder name
let name = normalize_storage_name_owned(name);
if let Err(reason) = validate_storage_name(&name) {
return Err(FolderError::InvalidFolderName(format!("{name}: {reason}")));
}
@@ -117,26 +126,15 @@ impl Folder {
.unwrap_or_default()
.as_secs();
// Store the path string for serialization compatibility
let path_string = storage_path.to_string();
Ok(Self {
id,
name,
storage_path,
path_string,
parent_id,
owner_id,
// In-memory constructor: callers that don't supply a
// drive_id are by definition stub/legacy paths (tests,
// pre-D0 fixtures, DTO round-trips). Real DB-backed
// folders flow through `with_timestamps_and_tree`.
drive_id: Uuid::nil(),
created_at: now,
modified_at: now,
tree_modified_at: now,
// Provenance is unknown for in-memory construction; the DB
// reconstruction path supplies real values.
created_by: None,
updated_by: None,
})
@@ -160,34 +158,6 @@ impl Folder {
name,
storage_path,
parent_id,
None,
Uuid::nil(),
created_at,
modified_at,
modified_at,
)
}
/// Creates a folder with specific timestamps and owner (legacy
/// constructor — `tree_modified_at` defaults to `modified_at`).
/// Prefer [`Folder::with_timestamps_and_tree`] for DB reconstruction
/// so the rollup ETag reflects descendant activity, not just this
/// row's own metadata.
pub fn with_timestamps_and_owner(
id: String,
name: String,
storage_path: StoragePath,
parent_id: Option<String>,
owner_id: Option<Uuid>,
created_at: u64,
modified_at: u64,
) -> FolderResult<Self> {
Self::with_timestamps_and_tree(
id,
name,
storage_path,
parent_id,
owner_id,
Uuid::nil(),
created_at,
modified_at,
@@ -209,7 +179,6 @@ impl Folder {
name: String,
storage_path: StoragePath,
parent_id: Option<String>,
owner_id: Option<Uuid>,
drive_id: Uuid,
created_at: u64,
modified_at: u64,
@@ -220,7 +189,6 @@ impl Folder {
name,
storage_path,
parent_id,
owner_id,
drive_id,
created_at,
modified_at,
@@ -239,7 +207,6 @@ impl Folder {
name: String,
storage_path: StoragePath,
parent_id: Option<String>,
owner_id: Option<Uuid>,
drive_id: Uuid,
created_at: u64,
modified_at: u64,
@@ -247,20 +214,16 @@ impl Folder {
created_by: Option<Uuid>,
updated_by: Option<Uuid>,
) -> FolderResult<Self> {
let name = normalize_storage_name(&name);
let name = normalize_storage_name_owned(name);
if let Err(reason) = validate_storage_name(&name) {
return Err(FolderError::InvalidFolderName(format!("{name}: {reason}")));
}
let path_string = storage_path.to_string();
Ok(Self {
id,
name,
storage_path,
path_string,
parent_id,
owner_id,
drive_id,
created_at,
modified_at,
@@ -270,6 +233,68 @@ impl Folder {
})
}
/// PG-row constructor: the per-listing-row hot path.
///
/// Takes the materialized `storage.folders.path` column by value and
/// splits it once via [`StoragePath::from_joined`] — when the stored
/// path is already canonical (every row the repository writes), the
/// input `String` is reused as `path_string` with zero copies,
/// replacing the old `from_string` split + `Display` re-join pair.
/// The owned `name` is NFC-normalized without the always-copy of the
/// borrowing variant (DB rows are NFC by invariant).
#[allow(clippy::too_many_arguments)]
pub fn from_materialized_row(
id: String,
name: String,
path: String,
parent_id: Option<String>,
drive_id: Uuid,
created_at: u64,
modified_at: u64,
tree_modified_at: u64,
created_by: Option<Uuid>,
updated_by: Option<Uuid>,
) -> FolderResult<Self> {
let name = normalize_storage_name_owned(name);
if let Err(reason) = validate_storage_name(&name) {
return Err(FolderError::InvalidFolderName(format!("{name}: {reason}")));
}
let storage_path = StoragePath::from_joined(path);
Ok(Self {
id,
name,
storage_path,
parent_id,
drive_id,
created_at,
modified_at,
tree_modified_at,
created_by,
updated_by,
})
}
/// Consume the entity and return all fields by ownership.
///
/// Use this when converting `Folder` into a DTO to avoid cloning
/// every `String` field (saves 3-4 heap allocations per folder).
pub fn into_parts(self) -> FolderParts {
FolderParts {
id: self.id,
name: self.name,
storage_path: self.storage_path,
parent_id: self.parent_id,
drive_id: self.drive_id,
created_at: self.created_at,
modified_at: self.modified_at,
tree_modified_at: self.tree_modified_at,
created_by: self.created_by,
updated_by: self.updated_by,
}
}
// Getters
pub fn id(&self) -> &str {
&self.id
@@ -284,7 +309,7 @@ impl Folder {
}
pub fn path_string(&self) -> &str {
&self.path_string
self.storage_path.as_str()
}
pub fn parent_id(&self) -> Option<&str> {
@@ -299,10 +324,6 @@ impl Folder {
self.modified_at
}
pub fn owner_id(&self) -> Option<Uuid> {
self.owner_id
}
/// Drive that owns this folder. Path-based lookups scope by
/// this axis (post-D0 invariant: `storage.folders.drive_id`
/// is `NOT NULL`).
@@ -381,8 +402,25 @@ impl Folder {
/// changed; the folder's own value stays untouched
/// (self-exclusion).
pub fn compute_etag(id: &str, tree_modified_at: u64) -> String {
let prefix: String = id.chars().take(16).collect();
format!("{}-{}", prefix, tree_modified_at)
use std::fmt::Write as _;
// Byte index just past the 16th char (whole string when shorter).
// `id` is a UUID string (ASCII) in practice, so this is
// effectively `min(len, 16)`, but `char_indices` keeps the slice
// char-boundary-safe for exotic fixture values — byte-identical
// to the old `chars().take(16).collect::<String>()` without the
// intermediate allocation.
let end = match id.char_indices().nth(16) {
Some((i, _)) => i,
None => id.len(),
};
// Single allocation: prefix + '-' + up to 20 digits (u64::MAX).
let mut etag = String::with_capacity(end + 1 + 20);
etag.push_str(&id[..end]);
etag.push('-');
let _ = write!(etag, "{tree_modified_at}");
etag
}
/// Creates a new Folder instance from a DTO
@@ -395,8 +433,8 @@ impl Folder {
created_at: u64,
modified_at: u64,
) -> Self {
// Create storage_path from the string
let storage_path = StoragePath::from_string(&path);
// Adopt the DTO path (canonical inputs reused with zero copies).
let storage_path = StoragePath::from_joined(path);
// Create directly without validation to avoid errors in DTO
// conversions. Still NFC-normalize so DTO-reconstructed
@@ -405,14 +443,12 @@ impl Folder {
// round-trips lose the real rollup signal, so callers that
// need a freshly-rolled-up etag must reload from the
// repository.
let name = normalize_storage_name(&name);
let name = normalize_storage_name_owned(name);
Self {
id,
name,
storage_path,
path_string: path,
parent_id,
owner_id: None,
// DTO round-trips lose drive_id (FolderDto carries it,
// but the legacy `from_dto` signature predates this
// change). Callers that need real scoping must reload
@@ -432,7 +468,7 @@ impl Folder {
/// Creates a new version of the folder with updated name
pub fn with_name(&self, new_name: String) -> FolderResult<Self> {
let new_name = normalize_storage_name(&new_name);
let new_name = normalize_storage_name_owned(new_name);
if let Err(reason) = validate_storage_name(&new_name) {
return Err(FolderError::InvalidFolderName(format!(
"{new_name}: {reason}"
@@ -446,9 +482,6 @@ impl Folder {
None => StoragePath::from_string(&new_name),
};
// Update string representation
let new_path_string = new_storage_path.to_string();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
@@ -458,9 +491,7 @@ impl Folder {
id: self.id.clone(),
name: new_name,
storage_path: new_storage_path,
path_string: new_path_string,
parent_id: self.parent_id.clone(),
owner_id: self.owner_id,
drive_id: self.drive_id,
created_at: self.created_at,
modified_at: now,
@@ -487,9 +518,6 @@ impl Folder {
None => StoragePath::from_string(&self.name), // Root
};
// Update string representation
let new_path_string = new_storage_path.to_string();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
@@ -499,9 +527,7 @@ impl Folder {
id: self.id.clone(),
name: self.name.clone(),
storage_path: new_storage_path,
path_string: new_path_string,
parent_id,
owner_id: self.owner_id,
drive_id: self.drive_id,
created_at: self.created_at,
modified_at: now,
@@ -515,12 +541,9 @@ impl Folder {
pub fn get_absolute_path<P: AsRef<std::path::Path>>(&self, root_path: P) -> std::path::PathBuf {
let mut result = std::path::PathBuf::from(root_path.as_ref());
// Skip leading '/' from path_string to avoid creating absolute path incorrectly
let relative_path = if self.path_string.starts_with('/') {
&self.path_string[1..]
} else {
&self.path_string
};
// Skip leading '/' to avoid creating an absolute path incorrectly
let path_string = self.storage_path.as_str();
let relative_path = path_string.strip_prefix('/').unwrap_or(path_string);
if !relative_path.is_empty() {
result.push(relative_path);
@@ -593,7 +616,6 @@ mod tests {
"folder".to_string(),
StoragePath::from_string("/folder"),
None,
None,
Uuid::nil(),
1_000,
2_000,
@@ -615,7 +637,6 @@ mod tests {
"a".to_string(),
StoragePath::from_string("/a"),
None,
None,
Uuid::nil(),
0,
0,
@@ -627,7 +648,6 @@ mod tests {
"b".to_string(),
StoragePath::from_string("/b"),
None,
None,
Uuid::nil(),
0,
0,
@@ -650,7 +670,6 @@ mod tests {
"folder".to_string(),
StoragePath::from_string("/folder"),
None,
None,
Uuid::nil(),
1_000,
2_000,
@@ -662,7 +681,6 @@ mod tests {
"folder".to_string(),
StoragePath::from_string("/folder"),
None,
None,
Uuid::nil(),
1_000,
2_000,
+10 -5
View File
@@ -180,13 +180,18 @@ impl TryFrom<&str> for ShareItemType {
type Error = ShareError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
match s.to_lowercase().as_str() {
"file" => Ok(ShareItemType::File),
"folder" => Ok(ShareItemType::Folder),
_ => Err(ShareError::ValidationError(format!(
// ASCII case-insensitive compare against the two literals instead of a
// throwaway Unicode `to_lowercase()` String — byte-identical acceptance
// for the ASCII targets "file"/"folder" (1 → 0 allocs/call).
if s.eq_ignore_ascii_case("file") {
Ok(ShareItemType::File)
} else if s.eq_ignore_ascii_case("folder") {
Ok(ShareItemType::Folder)
} else {
Err(ShareError::ValidationError(format!(
"Invalid item type: {}",
s
))),
)))
}
}
}
+25
View File
@@ -7,6 +7,17 @@ pub enum TrashedItemType {
Folder,
}
/// Owned decomposition of a [`TrashedItem`] (see
/// [`TrashedItem::into_parts`]).
pub struct TrashedItemParts {
pub id: Uuid,
pub original_id: Uuid,
pub item_type: TrashedItemType,
pub name: String,
pub original_path: String,
pub trashed_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct TrashedItem {
id: Uuid,
@@ -98,6 +109,20 @@ impl TrashedItem {
self.deletion_date
}
/// Decompose into owned parts for DTO conversion — moves `name` /
/// `original_path` instead of the getter clones `to_dto` used to make
/// per trash row (benches/ROUND11.md; the File/Folder/Contact pattern).
pub fn into_parts(self) -> TrashedItemParts {
TrashedItemParts {
id: self.id,
original_id: self.original_id,
item_type: self.item_type,
name: self.name,
original_path: self.original_path,
trashed_at: self.trashed_at,
}
}
pub fn days_until_deletion(&self) -> i64 {
let now = Utc::now();
(self.deletion_date - now).num_days().max(0)
+174 -4
View File
@@ -11,12 +11,20 @@ pub enum UserRole {
User,
}
impl UserRole {
/// Canonical wire/DB spelling — the single source the `Display` impl
/// and every hot-path role render go through (no format machinery).
pub fn as_str(self) -> &'static str {
match self {
UserRole::Admin => "admin",
UserRole::User => "user",
}
}
}
impl std::fmt::Display for UserRole {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
UserRole::Admin => write!(f, "admin"),
UserRole::User => write!(f, "user"),
}
f.write_str(self.as_str())
}
}
@@ -107,9 +115,108 @@ pub struct User {
/// and opts out, subsequent shares from other granters honor the
/// flag.
notify_on_share: bool,
/// Opaque UI preferences bag (PR — this session). Stored as JSONB
/// on `auth.users.ui_preferences`; the server NEVER inspects the
/// contents. This is the SPA's cross-device backing store for pure
/// UI toggles (hide-dotfiles, view mode, sidebar collapse, …).
///
/// Merge semantics live in the repo layer: `PATCH /me/profile` does
/// a SHALLOW merge via `ui_preferences || $1::jsonb`, so partial
/// writes from one device don't clobber keys set on another.
///
/// Load-bearing rule: if a preference EVER becomes something the
/// server reads (like `preferred_locale` did), promote it out of
/// this bag into a typed column. Keep this field for UI-only
/// toggles.
///
/// Invariant: always a JSON object (enforced by the schema CHECK
/// `users_ui_preferences_is_object`). Empty bag is `{}`, never
/// `null` or missing.
ui_preferences: serde_json::Value,
}
/// Owned decomposition of a [`User`] (mirrors `FileParts` / `FolderParts` /
/// `ContactParts`). Lets a consumer MOVE the heap fields out instead of cloning
/// them through the borrowing accessors — notably `image` (a data URI up to
/// 512 KiB) and `ui_preferences` (a JSON tree). See `UserDto::from`
/// (benches/ROUND20.md §A2).
pub struct UserParts {
pub id: Uuid,
pub username: Option<String>,
pub email: String,
pub password_hash: Option<String>,
pub role: UserRole,
pub storage_quota_bytes: i64,
pub storage_used_bytes: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
pub active: bool,
pub oidc_provider: Option<String>,
pub oidc_subject: Option<String>,
pub image: Option<String>,
pub is_external: bool,
pub given_name: Option<String>,
pub family_name: Option<String>,
pub email_verified_at: Option<DateTime<Utc>>,
pub preferred_locale: Option<String>,
pub notify_on_share: bool,
pub ui_preferences: serde_json::Value,
}
impl User {
/// Decompose into [`UserParts`], moving every owned field out. The
/// exhaustive destructure is compiler-checked, so a future field can't be
/// silently dropped.
pub fn into_parts(self) -> UserParts {
let User {
id,
username,
email,
password_hash,
role,
storage_quota_bytes,
storage_used_bytes,
created_at,
updated_at,
last_login_at,
active,
oidc_provider,
oidc_subject,
image,
is_external,
given_name,
family_name,
email_verified_at,
preferred_locale,
notify_on_share,
ui_preferences,
} = self;
UserParts {
id,
username,
email,
password_hash,
role,
storage_quota_bytes,
storage_used_bytes,
created_at,
updated_at,
last_login_at,
active,
oidc_provider,
oidc_subject,
image,
is_external,
given_name,
family_name,
email_verified_at,
preferred_locale,
notify_on_share,
ui_preferences,
}
}
/// Create a new user.
///
/// One unified constructor for every kind of user (internal, OIDC-linked,
@@ -205,6 +312,11 @@ impl User {
// `users_notify_on_share` mirrors this for rows reconstructed
// from disk without going through `new`.
notify_on_share: true,
// Empty bag on creation. The SPA writes into it via
// `PATCH /me/profile { ui_preferences: {...} }` after
// login. Never NULL — the DB CHECK enforces JSON object
// shape.
ui_preferences: serde_json::json!({}),
})
}
@@ -249,6 +361,7 @@ impl User {
email_verified_at: None,
preferred_locale: None,
notify_on_share: true,
ui_preferences: serde_json::json!({}),
}
}
@@ -274,6 +387,10 @@ impl User {
email_verified_at: Option<DateTime<Utc>>,
preferred_locale: Option<String>,
notify_on_share: bool,
// Opaque UI-preferences bag. Callers reading from the DB pass
// `row.get("ui_preferences")`; tests that don't care can pass
// `serde_json::json!({})`.
ui_preferences: serde_json::Value,
) -> Self {
Self {
id,
@@ -296,6 +413,7 @@ impl User {
email_verified_at,
preferred_locale,
notify_on_share,
ui_preferences,
}
}
@@ -483,6 +601,47 @@ impl User {
}
}
/// Promote a currently-external user to an internal account.
/// Atomically flips the invariant-linked fields:
/// * `is_external` → false
/// * `password_hash` → provided (Some) or preserved (None)
/// * `storage_quota_bytes` → quota (external users had 0; DB CHECK
/// `users_external_no_storage` enforces the pair before this call
/// and would refuse a non-zero quota on an external row — the
/// write MUST flip `is_external` first, which happens
/// transactionally at persist time via the sqlx UPDATE).
///
/// Password is `Option<String>` because the service allows password-
/// less upgrades when magic-link login is available on the
/// deployment. When `None`, `password_hash` stays as it was (either
/// NULL, or a hash left over from an admin-created invitation —
/// externals don't authenticate with it either way).
///
/// Refuses if the caller is already internal — the upgrade path
/// only makes sense on `is_external = true` users. Service pre-
/// checks `user.is_external()` before calling; this guard is
/// belt-and-braces against a race.
///
/// Admin combo is impossible by construction: external + admin was
/// refused at creation (see `User::new`), so a promoted external
/// user always retains their `UserRole::User` — role isn't changed.
pub fn promote_to_internal(
&mut self,
password_hash: Option<String>,
storage_quota_bytes: i64,
) -> UserResult<()> {
if !self.is_external {
return Err(UserError::AlreadyInternal);
}
self.is_external = false;
if let Some(hash) = password_hash {
self.password_hash = Some(hash);
}
self.storage_quota_bytes = storage_quota_bytes;
self.updated_at = Utc::now();
Ok(())
}
pub fn set_image(&mut self, image: Option<String>) {
self.image = image;
self.updated_at = Utc::now();
@@ -536,6 +695,16 @@ impl User {
self.updated_at = Utc::now();
}
/// Opaque UI preferences bag. Read-only accessor for the DTO
/// conversion; mutation goes through the repo's shallow-merge SQL
/// (`UserPgRepository::update_ui_preferences`) rather than a
/// setter here — the DB is authoritative on the merged state
/// because two devices can PATCH concurrently and the merge has
/// to happen at write time, not at read time.
pub fn ui_preferences(&self) -> &serde_json::Value {
&self.ui_preferences
}
/// Claim or change the username. Runs the same validation as the
/// constructor — callers must still ensure uniqueness at the repo
/// level. Bumps `updated_at`. Used by the post-create profile-edit
@@ -722,6 +891,7 @@ mod tests {
None,
None,
true,
serde_json::json!({}),
)
}
+54 -16
View File
@@ -33,22 +33,45 @@ pub enum ErrorKind {
DatabaseError,
/// Storage quota exceeded
QuotaExceeded,
/// State conflict — the request is well-formed and permitted, but
/// the resource is in a state that refuses it (e.g. "drive must
/// be empty before delete"). Maps to HTTP 409. Distinct from
/// `AlreadyExists` (which is a uniqueness violation) so audit
/// readers can tell them apart.
Conflict,
/// RFC 7232 precondition failure — a caller-supplied conditional
/// (If-Match, or an internal compare-and-swap standing in for one)
/// did not hold against the resource's current state. Maps to
/// HTTP 412. Distinct from `Conflict` (409): this is specifically
/// "the state you thought you were writing against has moved."
PreconditionFailed,
}
impl ErrorKind {
/// Stable human-readable name; `Display` delegates here so the two can
/// never drift. Being `&'static` it lets the HTTP error path borrow the
/// value instead of allocating per response (benches/ROUND11.md §9).
pub fn as_str(&self) -> &'static str {
match self {
ErrorKind::NotFound => "Not Found",
ErrorKind::AlreadyExists => "Already Exists",
ErrorKind::InvalidInput => "Invalid Input",
ErrorKind::AccessDenied => "Access Denied",
ErrorKind::Timeout => "Timeout",
ErrorKind::InternalError => "Internal Error",
ErrorKind::NotImplemented => "Not Implemented",
ErrorKind::UnsupportedOperation => "Unsupported Operation",
ErrorKind::DatabaseError => "Database Error",
ErrorKind::QuotaExceeded => "Quota Exceeded",
ErrorKind::Conflict => "Conflict",
ErrorKind::PreconditionFailed => "Precondition Failed",
}
}
}
impl Display for ErrorKind {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
match self {
ErrorKind::NotFound => write!(f, "Not Found"),
ErrorKind::AlreadyExists => write!(f, "Already Exists"),
ErrorKind::InvalidInput => write!(f, "Invalid Input"),
ErrorKind::AccessDenied => write!(f, "Access Denied"),
ErrorKind::Timeout => write!(f, "Timeout"),
ErrorKind::InternalError => write!(f, "Internal Error"),
ErrorKind::NotImplemented => write!(f, "Not Implemented"),
ErrorKind::UnsupportedOperation => write!(f, "Unsupported Operation"),
ErrorKind::DatabaseError => write!(f, "Database Error"),
ErrorKind::QuotaExceeded => write!(f, "Quota Exceeded"),
}
f.write_str(self.as_str())
}
}
@@ -84,11 +107,14 @@ impl DomainError {
/// Creates an entity not found error
pub fn not_found<S: Into<String>>(entity_type: &'static str, entity_id: S) -> Self {
let id = entity_id.into();
// Message first, then move the id — the old `Some(id.clone())`
// paid an extra allocation on every 404 construction.
let message = format!("{} not found: {}", entity_type, id);
Self {
kind: ErrorKind::NotFound,
entity_type,
entity_id: Some(id.clone()),
message: format!("{} not found: {}", entity_type, id),
entity_id: Some(id),
message,
source: None,
}
}
@@ -96,11 +122,12 @@ impl DomainError {
/// Creates an entity already exists error
pub fn already_exists<S: Into<String>>(entity_type: &'static str, entity_id: S) -> Self {
let id = entity_id.into();
let message = format!("{} already exists: {}", entity_type, id);
Self {
kind: ErrorKind::AlreadyExists,
entity_type,
entity_id: Some(id.clone()),
message: format!("{} already exists: {}", entity_type, id),
entity_id: Some(id),
message,
source: None,
}
}
@@ -176,6 +203,17 @@ impl DomainError {
}
}
/// Creates a precondition-failed error (RFC 7232 / CAS mismatch)
pub fn precondition_failed<S: Into<String>>(entity_type: &'static str, message: S) -> Self {
Self {
kind: ErrorKind::PreconditionFailed,
entity_type,
entity_id: None,
message: message.into(),
source: None,
}
}
/// Creates a validation error
pub fn validation_error<S: Into<String>>(message: S) -> Self {
Self {
@@ -6,6 +6,14 @@ use crate::domain::entities::contact::AddressBook;
pub type AddressBookRepositoryResult<T> = Result<T, DomainError>;
/// Repository interface for AddressBook entity operations.
///
/// Post-Round-3, access-control state lives in `storage.role_grants`.
/// The pre-Round-3 methods that read/wrote `carddav.address_book_shares`
/// (`get_shared_address_books`, `share_address_book`,
/// `unshare_address_book`, `get_address_book_shares`) have been removed
/// from this trait, and the backing table was dropped in
/// `20260906000002_drop_legacy_share_tables.sql`.
pub trait AddressBookRepository: Send + Sync + 'static {
async fn create_address_book(
&self,
@@ -16,32 +24,25 @@ pub trait AddressBookRepository: Send + Sync + 'static {
address_book: AddressBook,
) -> AddressBookRepositoryResult<AddressBook>;
async fn delete_address_book(&self, id: &Uuid) -> AddressBookRepositoryResult<()>;
/// Batch sibling of `get_address_book_by_id`: one `= ANY($1)`
/// round-trip for a page of grant-derived ids. Missing ids drop
/// out; ordering is not guaranteed.
async fn get_address_books_by_ids(
&self,
ids: &[Uuid],
) -> AddressBookRepositoryResult<Vec<AddressBook>>;
async fn get_address_book_by_id(
&self,
id: &Uuid,
) -> AddressBookRepositoryResult<Option<AddressBook>>;
/// Direct owner enumeration — same semantics as the calendar
/// counterpart. The service layer prefers
/// `authz.list_incoming_grants`, but internal maintenance paths
/// keep the owner-only lookup available.
async fn get_address_books_by_owner(
&self,
owner_id: Uuid,
) -> AddressBookRepositoryResult<Vec<AddressBook>>;
async fn get_shared_address_books(
&self,
user_id: Uuid,
) -> AddressBookRepositoryResult<Vec<AddressBook>>;
async fn get_public_address_books(&self) -> AddressBookRepositoryResult<Vec<AddressBook>>;
async fn share_address_book(
&self,
address_book_id: &Uuid,
user_id: Uuid,
can_write: bool,
) -> AddressBookRepositoryResult<()>;
async fn unshare_address_book(
&self,
address_book_id: &Uuid,
user_id: Uuid,
) -> AddressBookRepositoryResult<()>;
async fn get_address_book_shares(
&self,
address_book_id: &Uuid,
) -> AddressBookRepositoryResult<Vec<(String, bool)>>;
}
@@ -25,6 +25,23 @@ pub trait CalendarEventRepository: Send + Sync + 'static {
/// Finds a calendar event by its ID
async fn find_event_by_id(&self, id: &Uuid) -> CalendarEventRepositoryResult<CalendarEvent>;
/// Narrow projection of `find_event_by_id` for authorization gates:
/// just the owning `calendar_id`, without dragging the full row —
/// notably `ical_data`, the raw iCalendar body — off the wire.
async fn find_calendar_id_by_event_id(&self, id: &Uuid) -> CalendarEventRepositoryResult<Uuid>;
/// Cursor stream over every event of `calendar_id` in bundle order:
/// rows sorted by `(first occurrence per UID, uid, master-first,
/// start_time)` so a recurring master + its exception overrides
/// arrive adjacent and bundles appear in the first-appearance order
/// the buffered `start_time` listing produced. ONE scan+sort on the
/// server; the streaming CalDAV emitters cut pages at UID
/// boundaries so only a page of rows is ever resident.
fn stream_events_uid_order(
&self,
calendar_id: Uuid,
) -> futures::stream::BoxStream<'static, CalendarEventRepositoryResult<CalendarEvent>>;
/// Lists all events in a specific calendar
async fn list_events_by_calendar(
&self,
@@ -46,13 +63,35 @@ pub trait CalendarEventRepository: Send + Sync + 'static {
end: &DateTime<Utc>,
) -> CalendarEventRepositoryResult<Vec<CalendarEvent>>;
/// Finds an event by its iCalendar UID in a specific calendar
/// Finds an event by its iCalendar UID in a specific calendar.
///
/// **Master-only lookup.** Filters `recurrence_id IS NULL` so the
/// return value is unambiguous — the row that clients treat as
/// "the event with this UID" is the master. Per-instance override
/// rows share the UID but live under
/// `find_event_by_ical_uid_and_recurrence_id` (see #528).
async fn find_event_by_ical_uid(
&self,
calendar_id: &Uuid,
ical_uid: &str,
) -> CalendarEventRepositoryResult<Option<CalendarEvent>>;
/// Finds a specific per-instance exception override for a recurring
/// master (RFC 5545 §3.8.4.4). `recurrence_id` pinpoints which
/// occurrence of the master with the given UID is being targeted;
/// returns `None` if no override has been PUT for that instance
/// yet — which the PUT handler then uses to decide insert vs.
/// update.
///
/// The row is guaranteed unique by the partial index
/// `idx_calendar_events_exception_unique`.
async fn find_event_by_ical_uid_and_recurrence_id(
&self,
calendar_id: &Uuid,
ical_uid: &str,
recurrence_id: &DateTime<Utc>,
) -> CalendarEventRepositoryResult<Option<CalendarEvent>>;
/// Finds the events matching any of the given iCalendar UIDs in one
/// indexed query (`ical_uid = ANY(...)`). Used by CalDAV multiget so a
/// request for a handful of events never pays for the whole calendar.
+19 -36
View File
@@ -4,7 +4,14 @@ use uuid::Uuid;
pub type CalendarRepositoryResult<T> = Result<T, DomainError>;
/// Repository interface for Calendar entity operations
/// Repository interface for Calendar entity operations.
///
/// Post-Round-3, access-control state lives in `storage.role_grants` —
/// the pre-Round-3 methods that read/wrote `caldav.calendar_shares`
/// (`list_calendars_shared_with_user`, `user_has_calendar_access`,
/// `share_calendar`, `remove_calendar_sharing`, `get_calendar_shares`)
/// have been removed from this trait, and the backing table was dropped
/// in `20260906000002_drop_legacy_share_tables.sql`.
pub trait CalendarRepository: Send + Sync + 'static {
/// Creates a new calendar
async fn create_calendar(&self, calendar: Calendar) -> CalendarRepositoryResult<Calendar>;
@@ -18,7 +25,17 @@ pub trait CalendarRepository: Send + Sync + 'static {
/// Finds a calendar by its ID
async fn find_calendar_by_id(&self, id: &Uuid) -> CalendarRepositoryResult<Calendar>;
/// Lists all calendars for a specific user
/// Batch sibling of [`Self::find_calendar_by_id`]: one `= ANY($1)`
/// round-trip for a page of grant-derived ids. Missing ids drop out
/// (no per-id NotFound), matching the listing carve-out for
/// deleted/trashed races. Ordering is not guaranteed.
async fn find_calendars_by_ids(&self, ids: &[Uuid]) -> CalendarRepositoryResult<Vec<Calendar>>;
/// Lists all calendars owned by a specific user. Post-Round-3 the
/// service layer prefers `authz.list_incoming_grants` (surfaces
/// owned + shared in one union), but this direct lookup remains
/// available for internal maintenance / migration paths that need
/// owner-only enumeration without going through the engine.
async fn list_calendars_by_owner(
&self,
owner_id: Uuid,
@@ -31,12 +48,6 @@ pub trait CalendarRepository: Send + Sync + 'static {
owner_id: Uuid,
) -> CalendarRepositoryResult<Calendar>;
/// Lists calendars shared with a specific user
async fn list_calendars_shared_with_user(
&self,
user_id: Uuid,
) -> CalendarRepositoryResult<Vec<Calendar>>;
/// List public calendars
async fn list_public_calendars(
&self,
@@ -44,13 +55,6 @@ pub trait CalendarRepository: Send + Sync + 'static {
offset: i64,
) -> CalendarRepositoryResult<Vec<Calendar>>;
/// Checks if a user has access to a calendar
async fn user_has_calendar_access(
&self,
calendar_id: &Uuid,
user_id: Uuid,
) -> CalendarRepositoryResult<bool>;
/// Gets a custom property for a calendar
async fn get_calendar_property(
&self,
@@ -78,25 +82,4 @@ pub trait CalendarRepository: Send + Sync + 'static {
&self,
calendar_id: &Uuid,
) -> CalendarRepositoryResult<std::collections::HashMap<String, String>>;
/// Share calendar with another user
async fn share_calendar(
&self,
calendar_id: &Uuid,
user_id: Uuid,
access_level: &str,
) -> CalendarRepositoryResult<()>;
/// Remove calendar sharing for a user
async fn remove_calendar_sharing(
&self,
calendar_id: &Uuid,
user_id: Uuid,
) -> CalendarRepositoryResult<()>;
/// Get calendar sharing information (who has access to this calendar)
async fn get_calendar_shares(
&self,
calendar_id: &Uuid,
) -> CalendarRepositoryResult<Vec<(String, String)>>;
}
@@ -25,6 +25,14 @@ pub trait ContactRepository: Send + Sync + 'static {
address_book_id: &Uuid,
uids: &[String],
) -> ContactRepositoryResult<Vec<Contact>>;
/// Cursor stream over every contact of the book in the listing
/// order (`full_name, first_name, last_name`) — ONE scan+sort on
/// the server; the streaming CardDAV emitters page over it.
fn stream_contacts_by_book(
&self,
address_book_id: Uuid,
) -> futures::stream::BoxStream<'static, ContactRepositoryResult<Contact>>;
async fn get_contacts_by_address_book(
&self,
address_book_id: &Uuid,
@@ -68,6 +76,10 @@ pub trait ContactGroupRepository: Send + Sync + 'static {
) -> ContactRepositoryResult<()>;
async fn get_contacts_in_group(&self, group_id: &Uuid)
-> ContactRepositoryResult<Vec<Contact>>;
/// Membership count only — for group summaries that don't need the
/// contacts hydrated (each row carries the full vCard TEXT plus three
/// JSONB arrays; counting must not pay for any of that).
async fn count_contacts_in_group(&self, group_id: &Uuid) -> ContactRepositoryResult<i64>;
async fn get_groups_for_contact(
&self,
contact_id: &Uuid,
+147 -9
View File
@@ -52,7 +52,7 @@ pub struct DriveWithRootName {
/// of the root folder via JOIN at read time.
pub root_folder_name: String,
/// Highest role the calling user holds on this drive (direct OR
/// group-mediated). Populated by `list_for_subjects` (which already
/// group-mediated). Populated by `list_readable_by` (which already
/// JOINs `role_grants` for accessibility, so the role is in scope at
/// query time). `None` for repo methods called without a caller
/// context (`get_by_id`, `get_by_ids`, `find_default_for_user`,
@@ -164,23 +164,63 @@ pub trait DriveRepository: Send + Sync + 'static {
}
/// List drives the caller can read, resolved via `role_grants` for
/// `resource_type='drive'`. The caller's group memberships are
/// expanded by the engine's `subject_match_set`; that expanded set
/// is what this method's `subject_ids` argument carries.
/// `resource_type='drive'`. Group memberships (direct + transitive)
/// are expanded inline by the `storage.caller_group_ids(caller)`
/// SQL function — callers pass only the caller's uuid, no
/// expansion ceremony.
///
/// Returns rows in a stable order: default drive first (if any),
/// then by display name. The `/api/drives` handler relies on that
/// order for the picker UI without a follow-up sort.
async fn list_for_subjects(
/// Returned as `Arc<Vec<…>>`: warm hits are a refcount bump straight
/// off the per-user cache instead of a deep clone of every row's
/// Strings — this runs per DAV request with an explicit drive
/// selector.
async fn list_readable_by(
&self,
subject_types: &[&str],
subject_ids: &[Uuid],
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
caller_id: Uuid,
) -> Result<std::sync::Arc<Vec<DriveWithRootName>>, DriveRepositoryError>;
/// `true` when the drive holds no live (non-trashed) folders other
/// than its own root and no live files at all. Used by
/// `DriveManagementService::delete_drive` to enforce the
/// "empty-before-delete" rule — owners must clear / trash the
/// content first so a single click can't wipe a populated drive.
async fn is_empty(&self, drive_id: Uuid) -> Result<bool, DriveRepositoryError>;
/// Drop the cached readable-drive list for one user. Called by
/// service-layer code paths that mutate state affecting a specific
/// caller's drive listing (grant writes, membership changes) but
/// don't reach through the drive-repo itself. Default no-op — the
/// no-cache stubs need no plumbing.
async fn invalidate_readable_for_user(&self, _user_id: Uuid) {}
/// Drop every cached readable-drive list. Called when the affected
/// user set is unknown at this layer — group-subject grants, drive
/// deletion, policy edits, root-folder renames (drive.name is
/// sourced from the root folder, so a rename affects the listing
/// for every user with a grant on the drive). Default no-op.
fn invalidate_readable_all(&self) {}
/// Drop every entry in the "default drive per user" cache. Called
/// from paths that mutate a drive's display name or its root
/// folder id at the concrete cache level (root-folder rename is
/// the only one today). Same class of bug as
/// `invalidate_readable_all` — the cache holds a `DriveWithRootName`
/// with `root_folder_name` baked in, so a rename would otherwise
/// stay stale for the cache TTL. Default no-op.
fn invalidate_default_drive_all(&self) {}
/// Hard-delete a drive: its `role_grants` rows, its root folder,
/// and the drive row itself, in one transaction. Caller is
/// responsible for ensuring `is_empty` first; this method does
/// **not** re-check. Returns `NotFound` if the drive id is gone.
async fn delete_atomic(&self, drive_id: Uuid) -> Result<(), DriveRepositoryError>;
/// List every drive on the system, regardless of caller membership.
///
/// Used by the admin panel's `GET /api/admin/drives`. Distinct from
/// `list_for_subjects` (which filters by `role_grants`) because an
/// `list_readable_by` (which filters by `role_grants`) because an
/// admin who creates a shared drive for someone else has no grant
/// on it — but still needs to see, audit, and manage it. The HTTP
/// gate (admin-only middleware) is what makes the unrestricted
@@ -191,6 +231,104 @@ pub trait DriveRepository: Send + Sync + 'static {
/// necessarily a member, so the per-drive role would be misleading
/// here.
async fn list_all(&self) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
/// Resolve a file's owning drive policies in one round-trip. Used by
/// D5 enforcement points (`forbid_public_links`, `forbid_sharing`, …)
/// to gate per-resource actions without a separate file-lookup +
/// drive-lookup pair.
///
/// Returns `NotFound` when the file id is gone or its `drive_id`
/// doesn't resolve to a drive row (a state the no-orphan triggers
/// prevent in production, but the caller should still propagate the
/// 404 cleanly).
async fn get_policies_for_file(
&self,
file_id: Uuid,
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError>;
/// Resolve a folder's owning drive policies in one round-trip. Same
/// shape as [`Self::get_policies_for_file`].
async fn get_policies_for_folder(
&self,
folder_id: Uuid,
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError>;
/// Resolve a file's owning drive id + its drive's policies in one
/// round-trip. Used by D5 `forbid_cross_drive_move` enforcement —
/// the move-file service needs both pieces (drive id to compare
/// against the destination, policies to gate). Returns `NotFound`
/// when the file row or its drive_id doesn't resolve.
async fn get_drive_id_and_policies_for_file(
&self,
file_id: Uuid,
) -> Result<(Uuid, crate::domain::entities::drive::DrivePolicies), DriveRepositoryError>;
/// Same as [`Self::get_drive_id_and_policies_for_file`] for folders.
async fn get_drive_id_and_policies_for_folder(
&self,
folder_id: Uuid,
) -> Result<(Uuid, crate::domain::entities::drive::DrivePolicies), DriveRepositoryError>;
/// Resolve just the drive id of a folder — fast PK probe used by
/// the cross-drive-move gate to identify the move destination
/// (where we don't need policies, just the discriminator). Returns
/// `NotFound` when the folder row doesn't exist.
async fn drive_id_for_folder(&self, folder_id: Uuid) -> Result<Uuid, DriveRepositoryError>;
/// Merge the given partial policy bag into the drive's existing
/// `policies` JSONB, returning the updated bag. JSONB-level merge
/// preserves unknown keys already present on disk (the column stays
/// the canonical bag — see `DrivePolicies::from_value`). `caller_id`
/// is recorded for the audit log emitted at the service layer.
///
/// Caller is responsible for the `Manage` permission check; this
/// method does not re-verify.
///
/// `partial` is a raw JSON object carrying **only** the keys the
/// caller wants to change — the repo passes it verbatim to the
/// `policies || $partial` JSONB merge. Using the typed
/// `DrivePolicies` here would serialise every field (including
/// unset ones as `false`) and clobber other flags on the row;
/// keeping the merge on the raw `Value` preserves the
/// partial-update semantic the handler documents.
async fn update_policies(
&self,
drive_id: Uuid,
partial: &serde_json::Value,
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError>;
/// Set the drive-level storage quota on a **shared** drive.
///
/// `quota_bytes = None` means unlimited (matches the wire and DB
/// convention — `drives.quota_bytes` is nullable; a NULL row → the
/// storage-usage service treats it as no cap).
///
/// **Personal drives are refused at the service layer** — their
/// effective cap comes from the owner user's
/// `users.storage_quota_bytes` envelope (see the memory
/// `project_user_envelope_quota_model`). This method does not
/// re-check the kind; the service does, and only calls the repo
/// with a validated shared-drive id.
///
/// A newly-lowered quota can be **under** the drive's current
/// `used_bytes` — that's a deliberate soft-quota semantic. The
/// `storage_usage_service` gates NEW writes on
/// `used + delta <= quota`, so a shared drive already over its
/// freshly-reduced cap can only shrink (delete) until it comes back
/// under the limit; no existing content is retroactively touched.
///
/// Cache invalidation mirrors `update_policies` — the user-keyed
/// readable-drive-list caches carry the quota alongside the row so
/// they'd serve stale numbers otherwise; the default-drive cache
/// carries the DriveWithRootName which also includes the quota.
///
/// Returns the persisted post-mutation value so the caller can
/// echo it back in the audit log and API response.
async fn update_quota(
&self,
drive_id: Uuid,
quota_bytes: Option<i64>,
) -> Result<Option<i64>, DriveRepositoryError>;
}
/// Convenience: convert the canonical kind discriminator from its SQL
+94 -24
View File
@@ -13,6 +13,17 @@ use crate::domain::entities::folder::Folder;
use crate::domain::services::path_service::StoragePath;
use uuid::Uuid;
// NOTE on `caller_role` for the two listing methods below:
// We deliberately do NOT compute or return the caller's role per row.
// The frontend already fetches `/api/drives` (which surfaces
// `caller_role` per drive) and cross-references by `folder.drive_id` —
// see `MoveDialog.svelte` and the config/drive page. Adding
// `caller_role` to `FolderDto` would either (a) mean redundant
// server-side work for a client-side concern the client already
// handles, or (b) drag folder-level grant cascades into the query
// which is real cost for a rare edge case. Punted; see
// `project_caller_role_on_file_folder_dto` memory.
/// Domain port for folder persistence.
///
/// Defines the CRUD and management operations required for
@@ -51,13 +62,20 @@ pub trait FolderRepository: Send + Sync + 'static {
/// Lists folders within a parent folder
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError>;
/// Lists root-level folders owned by a specific user.
/// For non-root queries (parent_id is Some), ownership is implicit
/// because the parent already belongs to the user.
async fn list_folders_by_owner(
/// Lists root-level folders the caller can read — scoped through
/// drive-membership grants (`role_grants` on `resource_type='drive'`)
/// rather than the legacy `folders.user_id` column. Group memberships
/// are expanded inline by `storage.caller_group_ids($caller)` in the
/// SQL. Closes [[bug-root-folder-listing-legacy-user-id]] — root
/// folders admin created for other users but has no role on no
/// longer surface in the admin's `GET /api/folders`.
///
/// Non-root queries (parent_id != None) go through `list_folders`
/// with the parent already permission-checked at the service layer,
/// so this method carries no `parent_id` parameter.
async fn list_root_folders_for_caller(
&self,
parent_id: Option<&str>,
owner_id: Uuid,
caller_id: Uuid,
) -> Result<Vec<Folder>, DomainError>;
/// Lists folders with pagination
@@ -69,18 +87,43 @@ pub trait FolderRepository: Send + Sync + 'static {
include_total: bool,
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
/// Lists folders with pagination, scoped to a specific owner.
/// Combines the owner filtering of `list_folders_by_owner` with
/// the pagination of `list_folders_paginated`.
async fn list_folders_by_owner_paginated(
/// Paginated companion to `list_root_folders_for_caller` — same
/// drive-scoped predicate, adds LIMIT/OFFSET + optional
/// window-function COUNT.
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,
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
/// Keyset-paged listing of `parent_id`'s direct sub-folders in name
/// order — `name > $after_name ORDER BY name LIMIT $limit`, one bounded
/// index-range read per page off the partial unique index
/// `idx_folders_unique_name`. Streaming PROPFIND drains sub-folders
/// with this instead of `COUNT(*) OVER() … LIMIT/OFFSET`, which
/// window-aggregated and rescanned all N sub-folders on every page
/// (4.5x on a 5k-dir parent, benches/FOLDER-KEYSET.md). `has_next`
/// falls out of `rows.len() == limit` — no total needed.
///
/// The default implementation falls back to `list_folders` + in-memory
/// slice so stubs and mocks compile without changes.
async fn list_folders_batch(
&self,
parent_id: Option<&str>,
after_name: Option<&str>,
limit: usize,
) -> Result<Vec<Folder>, DomainError> {
let mut all = self.list_folders(parent_id).await?;
all.sort_by(|a, b| a.name().cmp(b.name()));
Ok(all
.into_iter()
.filter(|f| after_name.is_none_or(|a| f.name() > a))
.take(limit)
.collect())
}
/// Renames a folder. `caller_id` is stamped into `updated_by`
/// alongside the `updated_at = NOW()` bump (§14 provenance).
async fn rename_folder(
@@ -136,6 +179,21 @@ pub trait FolderRepository: Send + Sync + 'static {
/// Permanently deletes a folder (used by the trash)
async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError>;
/// File ids in the subtree rooted at `folder_id` (inclusive).
///
/// Single GiST scan on `storage.folders.lpath`. Service-layer paths
/// that delete a folder via bulk SQL (the PG cascade reaps descendant
/// files transparently) call this BEFORE the delete so they can fire
/// `on_file_deleted` per-file. Without it, file-id-keyed lifecycle
/// data (e.g. `ext-{file_id}.jpg` video thumbnails) leaks past the
/// cascade. See [[bug-folder-cascade-hooks-missing]].
///
/// Default: returns an empty vec (stubs / mocks).
async fn list_file_ids_in_subtree(&self, folder_id: &str) -> Result<Vec<String>, DomainError> {
let _ = folder_id;
Ok(Vec::new())
}
/// Lists every folder in a subtree rooted at `folder_id` (inclusive).
///
/// Uses ltree `<@` for a single GiST-indexed scan. The result is
@@ -147,31 +205,35 @@ pub trait FolderRepository: Send + Sync + 'static {
Ok(Vec::new())
}
/// Lists all descendant folders in a subtree (ltree-based).
/// Lists all descendant folders in a subtree (ltree-based), scoped
/// to drives the caller can read.
///
/// Returns all folders whose lpath is a descendant of the given folder's
/// lpath. Used for recursive search — O(1) SQL via GiST index instead
/// of O(N) recursive traversal.
/// Returns all folders whose lpath is a descendant of the given
/// folder's lpath. Used for recursive search — O(1) SQL via GiST
/// index instead of O(N) recursive traversal. Drive-membership
/// filtering (including group cascade via `caller_group_ids`) is
/// applied inline in the SQL.
///
/// The default implementation returns an empty vec (stubs / mocks).
async fn list_descendant_folders(
&self,
folder_id: &str,
name_contains: Option<&str>,
user_id: Uuid,
caller_id: Uuid,
) -> Result<Vec<Folder>, DomainError> {
let _ = (folder_id, name_contains, user_id);
let _ = (folder_id, name_contains, caller_id);
Ok(Vec::new())
}
/// Search folders with SQL-level filtering by name, user, and scope.
/// Search folders with SQL-level filtering by name and scope,
/// restricted to drives the caller can read.
///
/// - **Non-recursive** (`recursive = false`): searches direct children of
/// `parent_id` (or root folders when `None`).
/// - **Recursive with `parent_id`**: delegates to `list_descendant_folders`
/// (ltree GiST-indexed scan).
/// - **Recursive without `parent_id`**: searches ALL folders owned by
/// `user_id` with optional name filter in SQL.
/// - **Recursive without `parent_id`**: searches ALL folders in drives
/// the caller can read, with optional name filter in SQL.
///
/// The default implementation falls back to `list_folders` + in-memory
/// filter so that stubs and mocks compile without changes.
@@ -179,13 +241,13 @@ pub trait FolderRepository: Send + Sync + 'static {
&self,
parent_id: Option<&str>,
name_contains: Option<&str>,
user_id: Uuid,
caller_id: Uuid,
recursive: bool,
) -> Result<Vec<Folder>, DomainError> {
// Recursive with folder_id → use optimised ltree scan
if recursive && let Some(fid) = parent_id {
return self
.list_descendant_folders(fid, name_contains, user_id)
.list_descendant_folders(fid, name_contains, caller_id)
.await;
}
// Fallback: load + filter in memory (stubs / mocks)
@@ -207,13 +269,21 @@ pub trait FolderRepository: Send + Sync + 'static {
/// Results are ordered by relevance (exact > starts-with > contains) for
/// autocomplete suggestions.
///
/// `caller_id` scopes results to folders whose owning drive the caller
/// can Read (direct or group-mediated `role_grants`). Without it the
/// endpoint leaked names + paths across every tenant on the instance —
/// closed as AuthZ audit finding #1 (2026-07-12).
///
/// The default implementation falls back to `list_folders` + in-memory
/// filter so that stubs and mocks compile without changes.
/// filter so that stubs and mocks compile without changes. Stub-mode
/// callers already operate against a single tenant's data, so ignoring
/// `caller_id` here is safe; the PG impl enforces the real scope.
async fn suggest_folders_by_name(
&self,
parent_id: Option<&str>,
query: &str,
limit: usize,
_caller_id: uuid::Uuid,
) -> Result<Vec<Folder>, DomainError> {
let all = self.list_folders(parent_id).await?;
let q = query.to_lowercase();
@@ -13,6 +13,11 @@ pub trait PlaylistRepository: Send + Sync + 'static {
async fn find_playlist_by_id(&self, id: &Uuid) -> PlaylistRepositoryResult<Playlist>;
/// Batch sibling of [`Self::find_playlist_by_id`]: one `= ANY($1)`
/// round-trip for a page of grant-derived ids. Missing ids drop
/// out; ordering is not guaranteed.
async fn find_playlists_by_ids(&self, ids: &[Uuid]) -> PlaylistRepositoryResult<Vec<Playlist>>;
async fn list_playlists_by_owner(
&self,
owner_id: Uuid,
+1 -1
View File
@@ -27,7 +27,7 @@ pub trait TrashRepository: Send + Sync {
///
/// **Caller contract**: pass only drive UUIDs the caller has
/// `Permission::Delete` on (resolved by the service via
/// `DriveRepository::list_for_subjects` + role-bundle filter). This
/// `DriveRepository::list_readable_by` + role-bundle filter). This
/// repository performs no authorization — see
/// `TrashService::empty_trash` for the canonical call site.
async fn clear_trash(&self, drive_ids: &[Uuid]) -> Result<()>;
@@ -111,6 +111,10 @@ pub trait UserRepository: Send + Sync + 'static {
/// Lists users by role (admin or user)
async fn list_users_by_role(&self, role: &str) -> UserRepositoryResult<Vec<User>>;
/// Counts users with a given role via a scalar `COUNT(*)` — no row
/// hydration (benches/ROUND29.md §G).
async fn count_users_by_role(&self, role: &str) -> UserRepositoryResult<i64>;
/// Deletes a user
async fn delete_user(&self, user_id: Uuid) -> UserRepositoryResult<()>;
+65 -18
View File
@@ -78,12 +78,24 @@ pub enum Resource {
/// membership and policy bag. Added in D0; membership lives in
/// `storage.role_grants` (no separate `drive_members` table).
Drive(Uuid),
// Reserved for future use:
// Calendar(Uuid),
// Reserved for future use:
// AddressBook(Uuid),
// Reserved for future use:
// Playlist(Uuid),
/// A CalDAV calendar. Membership + sharing lives in
/// `storage.role_grants` with `resource_type='calendar'` —
/// replaces the pre-Round-3 dedicated `caldav.calendar_shares`
/// table and the `check_calendar_access` bespoke helper. No
/// cascade parent (calendars are top-level per user); the engine
/// resolves directly against `role_grants` on the resource.
Calendar(Uuid),
/// A CardDAV address book. Same shape as `Calendar` —
/// `storage.role_grants` with `resource_type='address_book'`
/// replaces `carddav.address_book_shares` and the
/// `check_address_book_access` bespoke helper.
AddressBook(Uuid),
/// A music playlist. Same shape as `Calendar`/`AddressBook` —
/// `storage.role_grants` with `resource_type='playlist'` replaces
/// the pre-Round-3 dedicated `music.playlist_shares` table and the
/// bespoke `user_has_access` / `user_can_write` helpers on
/// `MusicStorageAdapter`.
Playlist(Uuid),
}
impl Resource {
@@ -92,18 +104,20 @@ impl Resource {
Resource::Folder(_) => "folder",
Resource::File(_) => "file",
Resource::Drive(_) => "drive",
//Resource::Calendar(_) => "calendar",
//Resource::AddressBook(_) => "adressbook",
//Resource::Playlist(_) => "playlist",
Resource::Calendar(_) => "calendar",
Resource::AddressBook(_) => "address_book",
Resource::Playlist(_) => "playlist",
}
}
pub fn id(&self) -> Uuid {
match self {
Resource::Folder(id) | Resource::File(id) | Resource::Drive(id) => *id,
//| Resource::Calendar(id)
//| Resource::AddressBook(id)
//| Resource::Playlist(id)
Resource::Folder(id)
| Resource::File(id)
| Resource::Drive(id)
| Resource::Calendar(id)
| Resource::AddressBook(id)
| Resource::Playlist(id) => *id,
}
}
@@ -112,12 +126,40 @@ impl Resource {
"folder" => Some(Resource::Folder(id)),
"file" => Some(Resource::File(id)),
"drive" => Some(Resource::Drive(id)),
//"calendar" => Some(Resource::Calendar(id)),
//"adressbook" => Some(Resource::AddressBook(id)),
//"playlist" => Some(Resource::Playlist(id)),
"calendar" => Some(Resource::Calendar(id)),
"address_book" => Some(Resource::AddressBook(id)),
"playlist" => Some(Resource::Playlist(id)),
_ => None,
}
}
/// Parse `(item_type, item_id)` from an API-facing pair of strings
/// (favorites, recent, batch endpoints all take this shape).
/// Combines UUID parse + type mapping so callers stay one-line and
/// error shapes are identical across surfaces. Returns
/// `DomainError::new(InvalidInput, …)` on malformed input; callers
/// that need the anti-enum 404 shape do that separately by feeding
/// the parsed `Resource` into `authz.require(...)`.
pub fn parse(
item_type: &str,
item_id: &str,
) -> Result<Self, crate::common::errors::DomainError> {
use crate::common::errors::{DomainError, ErrorKind};
let uuid = Uuid::parse_str(item_id).map_err(|_| {
DomainError::new(
ErrorKind::InvalidInput,
"Resource",
format!("Invalid item UUID '{item_id}'"),
)
})?;
Self::from_parts(item_type, uuid).ok_or_else(|| {
DomainError::new(
ErrorKind::InvalidInput,
"Resource",
format!("Unsupported item type '{item_type}'"),
)
})
}
}
impl fmt::Display for Resource {
@@ -508,11 +550,16 @@ mod tests {
#[test]
fn resource_roundtrip() {
let id = Uuid::new_v4();
for r in [Resource::Folder(id), Resource::File(id)] {
for r in [
Resource::Folder(id),
Resource::File(id),
Resource::Calendar(id),
Resource::AddressBook(id),
Resource::Playlist(id),
] {
let back = Resource::from_parts(r.type_str(), r.id()).unwrap();
assert_eq!(r, back);
}
assert!(Resource::from_parts("calendar", id).is_none());
}
#[test]
+181 -61
View File
@@ -5,7 +5,7 @@
//! infrastructure/services/path_service.rs because it has file system dependencies.
use std::path::PathBuf;
use unicode_normalization::UnicodeNormalization;
use unicode_normalization::{IsNormalized, UnicodeNormalization, is_nfc_quick};
/// NFC-normalize a single file or folder name component.
///
@@ -25,7 +25,34 @@ use unicode_normalization::UnicodeNormalization;
/// (`migrate-nfc-filenames`) cleans up rows that pre-date this rule.
///
/// Pure function — no I/O, allocates one `String`.
///
/// Fast path: `is_nfc_quick` is a per-char table lookup that answers
/// `Yes` for virtually every name already in NFC — which is every name
/// loaded back from PostgreSQL (the DB invariant above) and every
/// ASCII name. That skips the full decompose/recompose state machine
/// this function otherwise runs once per row on every listing
/// (PROPFIND, folder listing, photos timeline). `Maybe`/`No` fall
/// through to the full pipeline.
pub fn normalize_storage_name(name: &str) -> String {
if is_nfc_quick(name.chars()) == IsNormalized::Yes {
return name.to_string();
}
name.nfc().collect()
}
/// Owned-input sibling of [`normalize_storage_name`].
///
/// The borrowing variant must always allocate a fresh `String` even when
/// the input is already NFC — which is every name loaded back from
/// PostgreSQL (DB invariant) and every ASCII name. Callers that own the
/// `String` (entity constructors receive `name: String` by value) were
/// paying that copy only to drop the original immediately. This variant
/// returns the input unchanged on the fast path: zero allocations per
/// row on every listing (PROPFIND, photos timeline, search).
pub fn normalize_storage_name_owned(name: String) -> String {
if is_nfc_quick(name.chars()) == IsNormalized::Yes {
return name;
}
name.nfc().collect()
}
@@ -49,10 +76,25 @@ pub fn validate_storage_name(name: &str) -> Result<(), &'static str> {
Ok(())
}
/// Represents a storage path in the domain (Value Object)
#[derive(Debug, Clone, PartialEq, Eq, Default)]
/// Represents a storage path in the domain (Value Object).
///
/// Stored as the single **canonical joined form**: `"/"` for the root, or
/// `/seg(/seg)*` with every segment safe (non-empty, not `.`/`..`, no
/// `/`). Round 11 replaced the old `segments: Vec<String>` representation
/// — one heap `String` per component built on EVERY hydrated listing row
/// even though the DTO path only ever consumed the joined form — with this
/// one-allocation shape; segment views are derived on demand
/// (benches/ROUND11.md §20: 4 000 → 1 000 allocs on a 500-row page).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoragePath {
segments: Vec<String>,
/// Canonical joined rendering (`Display`'s output).
joined: String,
}
impl Default for StoragePath {
fn default() -> Self {
Self::root()
}
}
impl StoragePath {
@@ -61,20 +103,30 @@ impl StoragePath {
!s.is_empty() && s != "." && s != ".." && !s.contains('/')
}
/// Builds the canonical joined form from an iterator of raw segments,
/// silently dropping unsafe ones. `cap` pre-sizes the buffer.
fn build<'a>(segments: impl Iterator<Item = &'a str>, cap: usize) -> Self {
let mut joined = String::with_capacity(cap);
for seg in segments.filter(|s| Self::is_safe_segment(s)) {
joined.push('/');
joined.push_str(seg);
}
if joined.is_empty() {
joined.push('/');
}
Self { joined }
}
/// Creates a new storage path, silently dropping any traversal segments
pub fn new(segments: Vec<String>) -> Self {
Self {
segments: segments
.into_iter()
.filter(|s| Self::is_safe_segment(s))
.collect(),
}
let cap = segments.iter().map(|s| s.len() + 1).sum();
Self::build(segments.iter().map(String::as_str), cap)
}
/// Creates an empty path (root)
pub fn root() -> Self {
Self {
segments: Vec::new(),
joined: "/".to_string(),
}
}
@@ -83,83 +135,148 @@ impl StoragePath {
/// Traversal segments (`.`, `..`) are silently stripped to prevent
/// path-traversal attacks.
pub fn from_string(path: &str) -> Self {
let segments = path
.split('/')
.filter(|s| Self::is_safe_segment(s))
.map(|s| s.to_string())
.collect();
Self { segments }
Self::build(path.split('/'), path.len() + 1)
}
/// One-pass builder for PG listing rows: materialized folder path +
/// file name → the canonical joined path.
///
/// Byte-equivalence with the historical segment chain holds because
/// concatenating with a `/` separator distributes over `split('/')`:
/// `(fp + "/" + name).split('/') == fp.split('/') ⧺ name.split('/')`,
/// and the joined form is exactly `Display`'s `/`-prefixed rendering
/// of the surviving segments (root renders as `"/"`).
pub fn from_folder_and_name(folder_path: Option<&str>, file_name: &str) -> Self {
let fp = folder_path.unwrap_or("");
Self::build(
fp.split('/').chain(file_name.split('/')),
fp.len() + file_name.len() + 2,
)
}
/// Wrapper for a pre-joined materialized path (the
/// `storage.folders.path` column).
///
/// When the input is already canonical (leading `/`, no empty/`.`/`..`
/// segments, no trailing `/`) — which is every row the repository
/// writes — the input `String` is adopted with zero copies.
/// Non-canonical inputs fall back to the filtering rebuild and produce
/// exactly what `from_string(&path)` yields.
pub fn from_joined(path: String) -> Self {
if Self::is_canonical_joined(&path) {
return Self { joined: path };
}
Self::from_string(&path)
}
/// `true` when `path` is exactly `Display`'s canonical rendering of
/// its own segments: `"/"` alone, or `/seg(/seg)*` where every
/// segment is safe. One scan, no allocations.
fn is_canonical_joined(path: &str) -> bool {
if path == "/" {
return true;
}
if !path.starts_with('/') || path.ends_with('/') {
return false;
}
path[1..].split('/').all(Self::is_safe_segment)
}
/// Creates a path from a PathBuf
pub fn from(path_buf: PathBuf) -> Self {
let segments = path_buf
.components()
.filter_map(|c| match c {
std::path::Component::Normal(os_str) => Some(os_str.to_string_lossy().to_string()),
_ => None,
})
.collect();
Self { segments }
let mut joined = String::new();
for c in path_buf.components() {
if let std::path::Component::Normal(os_str) = c {
let seg = os_str.to_string_lossy();
if Self::is_safe_segment(&seg) {
joined.push('/');
joined.push_str(&seg);
}
}
}
if joined.is_empty() {
joined.push('/');
}
Self { joined }
}
/// Appends a segment to the path, consuming `self` so the existing
/// segment buffer is reused instead of deep-cloned.
/// buffer is reused instead of deep-cloned.
///
/// Traversal segments (`.`, `..`) and segments containing `/` are
/// silently ignored to prevent path-traversal attacks.
pub fn join(mut self, segment: &str) -> Self {
if Self::is_safe_segment(segment) {
self.segments.push(segment.to_string());
if self.joined == "/" {
self.joined.clear();
}
self.joined.push('/');
self.joined.push_str(segment);
}
self
}
/// Gets the file name (last segment)
pub fn file_name(&self) -> Option<String> {
self.segments.last().cloned()
if self.joined == "/" {
None
} else {
self.joined.rsplit('/').next().map(str::to_string)
}
}
/// Gets the parent directory path
pub fn parent(&self) -> Option<Self> {
if self.segments.is_empty() {
None
} else {
let parent_segments = self.segments[..self.segments.len() - 1].to_vec();
Some(Self {
segments: parent_segments,
})
if self.joined == "/" {
return None;
}
let cut = self.joined.rfind('/').expect("canonical path has '/'");
Some(if cut == 0 {
Self::root()
} else {
Self {
joined: self.joined[..cut].to_string(),
}
})
}
/// Checks if the path is empty (is the root)
pub fn is_empty(&self) -> bool {
self.segments.is_empty()
self.joined == "/"
}
}
impl std::fmt::Display for StoragePath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.segments.is_empty() {
write!(f, "/")
} else {
write!(f, "/{}", self.segments.join("/"))
}
f.write_str(&self.joined)
}
}
impl StoragePath {
/// Returns the path representation as a string
pub fn as_str(&self) -> &str {
// Note: The implementation should really store the string,
// but here we do a temporary implementation that always returns "/"
// This is only used for the get_folder_path_str implementation
"/"
/// The canonical joined form as an owned `String` (one memcpy).
pub fn to_path_string(&self) -> String {
self.joined.clone()
}
/// Gets the path segments
pub fn segments(&self) -> &[String] {
&self.segments
/// Consume `self`, yielding the canonical joined `String` with zero
/// copies. This is the row→entity→DTO hand-off path.
pub fn into_joined(self) -> String {
self.joined
}
/// Returns the path representation as a string (canonical joined form).
pub fn as_str(&self) -> &str {
&self.joined
}
/// Iterates the path segments (derived views over the joined form).
pub fn segments(&self) -> impl Iterator<Item = &str> {
let inner = if self.joined == "/" {
""
} else {
&self.joined[1..]
};
inner.split('/').filter(|s| !s.is_empty())
}
}
@@ -170,7 +287,10 @@ mod tests {
#[test]
fn test_storage_path_from_string() {
let path = StoragePath::from_string("folder/subfolder/file.txt");
assert_eq!(path.segments(), &["folder", "subfolder", "file.txt"]);
assert_eq!(
path.segments().collect::<Vec<_>>(),
&["folder", "subfolder", "file.txt"]
);
assert_eq!(path.to_string(), "/folder/subfolder/file.txt");
}
@@ -206,19 +326,19 @@ mod tests {
#[test]
fn test_from_string_strips_dot_dot() {
let path = StoragePath::from_string("../../etc/passwd");
assert_eq!(path.segments(), &["etc", "passwd"]);
assert_eq!(path.segments().collect::<Vec<_>>(), &["etc", "passwd"]);
}
#[test]
fn test_from_string_strips_single_dot() {
let path = StoragePath::from_string("folder/./file.txt");
assert_eq!(path.segments(), &["folder", "file.txt"]);
assert_eq!(path.segments().collect::<Vec<_>>(), &["folder", "file.txt"]);
}
#[test]
fn test_from_string_strips_mixed_traversal() {
let path = StoragePath::from_string("a/../b/./c/../../d");
assert_eq!(path.segments(), &["a", "b", "c", "d"]);
assert_eq!(path.segments().collect::<Vec<_>>(), &["a", "b", "c", "d"]);
}
#[test]
@@ -231,13 +351,13 @@ mod tests {
#[test]
fn test_new_strips_traversal_segments() {
let path = StoragePath::new(vec!["..".into(), "etc".into(), ".".into(), "passwd".into()]);
assert_eq!(path.segments(), &["etc", "passwd"]);
assert_eq!(path.segments().collect::<Vec<_>>(), &["etc", "passwd"]);
}
#[test]
fn test_new_strips_empty_segments() {
let path = StoragePath::new(vec!["a".into(), "".into(), "b".into()]);
assert_eq!(path.segments(), &["a", "b"]);
assert_eq!(path.segments().collect::<Vec<_>>(), &["a", "b"]);
}
#[test]
@@ -245,14 +365,14 @@ mod tests {
let base = StoragePath::from_string("folder");
let joined = base.join("..");
// ".." is silently ignored — path stays unchanged
assert_eq!(joined.segments(), &["folder"]);
assert_eq!(joined.segments().collect::<Vec<_>>(), &["folder"]);
}
#[test]
fn test_join_rejects_single_dot() {
let base = StoragePath::from_string("folder");
let joined = base.join(".");
assert_eq!(joined.segments(), &["folder"]);
assert_eq!(joined.segments().collect::<Vec<_>>(), &["folder"]);
}
#[test]
@@ -260,7 +380,7 @@ mod tests {
let base = StoragePath::from_string("folder");
let joined = base.join("sub/../../etc/passwd");
// Segment contains '/' → silently ignored
assert_eq!(joined.segments(), &["folder"]);
assert_eq!(joined.segments().collect::<Vec<_>>(), &["folder"]);
}
#[test]
@@ -269,8 +389,8 @@ mod tests {
// PathBuf Component::Normal only yields the normal parts
// On most platforms this strips . and ..
// but regardless, our from() only accepts Component::Normal
assert!(!path.segments().contains(&"..".to_string()));
assert!(!path.segments().contains(&".".to_string()));
assert!(!path.segments().any(|s| s == ".."));
assert!(!path.segments().any(|s| s == "."));
}
// ── NFC normalization tests ─────────────────────────────────