fix(filename): fix uniform encoding encoding (NFC)

This commit is contained in:
Edouard Vanbelle
2026-09-04 22:19:29 +02:00
parent 56da7e2365
commit 624fa59f24
12 changed files with 556 additions and 20 deletions
@@ -7,6 +7,7 @@ use crate::domain::entities::contact::AddressBook;
use crate::domain::repositories::address_book_repository::{
AddressBookRepository, AddressBookRepositoryResult,
};
use crate::domain::services::path_service::normalize_storage_name;
pub struct AddressBookPgRepository {
pool: Arc<PgPool>,
@@ -40,6 +41,15 @@ impl AddressBookRepository for AddressBookPgRepository {
&self,
address_book: AddressBook,
) -> AddressBookRepositoryResult<AddressBook> {
// NFC-normalize the caller-supplied display name at the last
// touch before bind. Same choke-point pattern as the storage.*
// repos — the entity constructor's normalization is bypassed by
// every real production path (`AddressBook::from_raw`
// reconstructs from DB bytes; `AddressBook::new` goes through
// an inbound DTO that may or may not have been touched).
// Enforcing here means every carddav write surface — DAV
// `MKCOL`, REST create — lands in NFC regardless.
let normalized_name = normalize_storage_name(address_book.name());
let row = sqlx::query(
r#"
INSERT INTO carddav.address_books (id, name, owner_id, description, color, is_public, created_at, updated_at)
@@ -48,7 +58,7 @@ impl AddressBookRepository for AddressBookPgRepository {
"#
)
.bind(address_book.id())
.bind(address_book.name())
.bind(&normalized_name)
.bind(address_book.owner_id())
.bind(address_book.description())
.bind(address_book.color())
@@ -76,6 +86,8 @@ impl AddressBookRepository for AddressBookPgRepository {
&self,
address_book: AddressBook,
) -> AddressBookRepositoryResult<AddressBook> {
// NFC-normalize on rename — see `create_address_book` for the why.
let normalized_name = normalize_storage_name(address_book.name());
let now = Utc::now();
let row = sqlx::query(
r#"
@@ -85,7 +97,7 @@ impl AddressBookRepository for AddressBookPgRepository {
RETURNING id, name, owner_id, description, color, is_public, created_at, updated_at
"#,
)
.bind(address_book.name())
.bind(&normalized_name)
.bind(address_book.description())
.bind(address_book.color())
.bind(address_book.is_public())
@@ -7,6 +7,7 @@ use crate::domain::entities::calendar::Calendar;
use crate::domain::repositories::calendar_repository::{
CalendarRepository, CalendarRepositoryResult,
};
use crate::domain::services::path_service::normalize_storage_name;
pub struct CalendarPgRepository {
pool: Arc<PgPool>,
@@ -38,6 +39,15 @@ impl CalendarPgRepository {
impl CalendarRepository for CalendarPgRepository {
async fn create_calendar(&self, calendar: Calendar) -> CalendarRepositoryResult<Calendar> {
// NFC-normalize the caller-supplied display name at the last
// touch before bind — same choke-point pattern the storage.files
// / storage.folders repos use (see docs/plan/nfc-normalization.md
// / migrate.rs module doc). macOS CalDAV clients emit NFD in the
// display-name field just as Finder does in the filename field;
// NC-desktop / Thunderbird would then miss the calendar on their
// NFC-normalized lookup path. Same class of bug as
// AtalayaLabs/OxiCloud#706, different table.
let normalized_name = normalize_storage_name(calendar.name());
let row = sqlx::query(
r#"
INSERT INTO caldav.calendars (id, name, owner_id, description, color, is_public, created_at, updated_at)
@@ -46,7 +56,7 @@ impl CalendarRepository for CalendarPgRepository {
"#
)
.bind(calendar.id())
.bind(calendar.name())
.bind(&normalized_name)
.bind(calendar.owner_id())
.bind(calendar.description())
.bind(calendar.color())
@@ -75,6 +85,8 @@ impl CalendarRepository for CalendarPgRepository {
}
async fn update_calendar(&self, calendar: Calendar) -> CalendarRepositoryResult<Calendar> {
// NFC-normalize on rename — see `create_calendar` for the why.
let normalized_name = normalize_storage_name(calendar.name());
let now = Utc::now();
let row = sqlx::query(
r#"
@@ -84,7 +96,7 @@ impl CalendarRepository for CalendarPgRepository {
RETURNING id, name, owner_id, description, color, is_public, created_at, updated_at
"#,
)
.bind(calendar.name())
.bind(&normalized_name)
.bind(calendar.description())
.bind(calendar.color())
.bind(false) // is_public doesn't exist as a field
@@ -11,6 +11,7 @@ use crate::domain::entities::contact::{Contact, ContactGroup};
use crate::domain::repositories::contact_repository::{
ContactGroupRepository, ContactRepositoryResult,
};
use crate::domain::services::path_service::normalize_storage_name;
pub struct ContactGroupPgRepository {
pool: Arc<PgPool>,
@@ -24,12 +25,19 @@ impl ContactGroupPgRepository {
impl ContactGroupRepository for ContactGroupPgRepository {
async fn create_group(&self, group: ContactGroup) -> ContactRepositoryResult<ContactGroup> {
// NFC-normalize at the last touch before bind — same choke-point
// pattern as the storage.* / caldav.* / carddav.address_books
// repos. Group display names on macOS Contacts sync as NFD
// (Address Book pushes decomposed forms in vCard KIND=group);
// NC-desktop / Thunderbird would then miss the group on their
// NFC-normalized lookup.
let normalized_name = normalize_storage_name(group.name());
sqlx::query(
"INSERT INTO carddav.contact_groups (id, address_book_id, name, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)"
)
.bind(group.id())
.bind(group.address_book_id())
.bind(group.name())
.bind(&normalized_name)
.bind(group.created_at())
.bind(group.updated_at())
.execute(self.pool.as_ref())
@@ -40,8 +48,10 @@ impl ContactGroupRepository for ContactGroupPgRepository {
}
async fn update_group(&self, group: ContactGroup) -> ContactRepositoryResult<ContactGroup> {
// NFC-normalize on rename — see `create_group` for the why.
let normalized_name = normalize_storage_name(group.name());
sqlx::query("UPDATE carddav.contact_groups SET name = $1, updated_at = $2 WHERE id = $3")
.bind(group.name())
.bind(&normalized_name)
.bind(Utc::now())
.bind(group.id())
.execute(self.pool.as_ref())
@@ -19,6 +19,7 @@ use crate::domain::entities::drive::{Drive, DriveKind};
use crate::domain::repositories::drive_repository::{
DriveRepository, DriveRepositoryError, DriveWithRootName,
};
use crate::domain::services::path_service::normalize_storage_name;
/// Decode a `d.policies` JSONB column straight into `DrivePolicies` via
/// `sqlx::types::Json<T>` — a single `serde_json::from_slice` over the raw JSONB
@@ -395,6 +396,13 @@ impl DriveRepository for DrivePgRepository {
quota_bytes: Option<i64>,
granted_by: Uuid,
) -> Result<DriveWithRootName, DriveRepositoryError> {
// NFC-normalize the admin-supplied shared-drive root name — same
// reasoning as `folder_db_repository::create_folder`. Even though
// this write is admin-only (not end-user-driven), the field feeds
// straight into `storage.folders.name` and WebDAV path lookups
// against it must match what NFC-normalizing clients send.
let name = normalize_storage_name(name);
// Same four-write transaction shape as `create_personal_drive_atomic`
// (see that method for the why-not-CTE explanation). Differences:
// - `kind='shared'`, `default_for_user=NULL`.
@@ -13,6 +13,7 @@ use crate::application::ports::external_mount_ports::{
ExternalMountRecord, ExternalMountRepositoryPort, NewExternalMount,
};
use crate::domain::errors::DomainError;
use crate::domain::services::path_service::normalize_storage_name;
/// PostgreSQL implementation of [`ExternalMountRepositoryPort`].
pub struct ExternalMountPgRepository {
@@ -93,6 +94,14 @@ impl ExternalMountRepositoryPort for ExternalMountPgRepository {
}
async fn create(&self, mount: &NewExternalMount) -> Result<(), DomainError> {
// NFC-normalize the admin-supplied display label at the last
// touch before bind. Admin-facing (not end-user drag-drop) so
// NFD is unlikely, but the invariant matches the storage.*
// pattern — the sibling folder row (created via
// `folder_db_repository::create_folder`, which already
// normalizes) and this admin label should stay byte-consistent
// on any table.
let normalized_name = normalize_storage_name(&mount.name);
sqlx::query(
"INSERT INTO storage.external_mounts
(mount_folder_id, kind, config, name, owner_id, read_only)
@@ -101,7 +110,7 @@ impl ExternalMountRepositoryPort for ExternalMountPgRepository {
.bind(mount.mount_folder_id)
.bind(&mount.kind)
.bind(&mount.config)
.bind(&mount.name)
.bind(&normalized_name)
.bind(mount.owner_id)
.bind(mount.read_only)
.execute(self.pool.as_ref())
@@ -17,6 +17,7 @@ use crate::application::dtos::display_helpers::category_order_for;
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort};
use crate::common::errors::DomainError;
use crate::domain::entities::file::File;
use crate::domain::services::path_service::{normalize_storage_name, normalize_storage_name_owned};
use super::transaction_utils::retry_on_deadlock;
use crate::infrastructure::services::dedup_service::DedupService;
@@ -281,6 +282,16 @@ impl FileBlobWriteRepository {
size: u64,
caller_id: Uuid,
) -> Result<File, DomainError> {
// NFC-normalize at the last touch before the DB bind. Same
// reasoning as `folder_db_repository::create_folder`: every write
// surface that lands here — REST multipart upload, by-hash instant
// upload, chunked-upload complete, WOPI create-fallback, WebDAV
// PUT, NC PUT, NC chunked-upload assemble — passes raw client
// bytes. macOS Finder emits NFD; canonicalising once here closes
// every audited entry-point at one choke-point. `is_nfc_quick`
// fast path is one table-lookup for the ~99% of names already NFC.
let name = normalize_storage_name_owned(name);
// Root files have no parent folder to derive an owner from — keep the
// previous resolve_user_id(None) contract (release the ref, error out).
let Some(fid) = folder_id.as_deref() else {
@@ -630,7 +641,14 @@ impl FileWritePort for FileBlobWriteRepository {
// folder's owner as the author when Adam copied a file into
// Alice's folder.
let target_fid = target_folder_id.clone();
let rename_to = new_name.map(|s| s.to_string());
// NFC-normalize the destination name at the last touch before the
// bind. `new_name = None` means "keep the source's stored name" —
// that path is already normalized (either by an earlier write here
// or, for pre-fix rows, deliberately left as-is per operator
// decision to not touch historical NFD content). Only fresh
// client-supplied `new_name` needs the pass; WebDAV `COPY` with a
// Destination header renaming a file is the canonical caller.
let rename_to = new_name.map(normalize_storage_name);
let row = retry_on_deadlock("files.copy", || async {
let mut tx = self.pool.begin().await?;
@@ -764,6 +782,11 @@ impl FileWritePort for FileBlobWriteRepository {
new_name: &str,
caller_id: Uuid,
) -> Result<File, DomainError> {
// NFC-normalize the client-supplied name at the last touch — same
// reasoning as `save_file_with_blob_impl`. REST rename, WebDAV
// MOVE-with-rename, NC MOVE-with-rename all funnel here.
let new_name = normalize_storage_name(new_name);
// §14: `updated_by = $3` (caller_id), see move_file.
let row = sqlx::query_as::<
_,
@@ -789,7 +812,7 @@ impl FileWritePort for FileBlobWriteRepository {
created_by, updated_by
"#,
)
.bind(new_name)
.bind(&new_name)
.bind(file_id)
.bind(caller_id)
.fetch_optional(self.pool.as_ref())
@@ -877,6 +900,14 @@ impl FileWritePort for FileBlobWriteRepository {
size: u64,
caller_id: Uuid,
) -> Result<(File, PathBuf), DomainError> {
// NFC-normalize at the last touch before the DB bind — same
// reasoning as `save_file_with_blob_impl`. Deferred registration
// is the write-behind cache's fast-path (row up first, blob
// hash filled in on the async callback); it takes fresh client
// input via chunked-upload finalize among others, so NFD is
// reachable here too.
let name = normalize_storage_name_owned(name);
// For deferred registration we use a placeholder hash.
// The write-behind cache will call update_file_content later.
let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000";
@@ -1074,6 +1105,13 @@ impl FileWritePort for FileBlobWriteRepository {
target_parent_id: Option<String>,
dest_name: Option<String>,
) -> Result<CopyFolderTreeResult, DomainError> {
// NFC-normalize the caller-supplied rename before handing off to
// the PG stored function. `dest_name = None` keeps the source's
// stored name (already normalized on ingest for post-fix rows;
// pre-fix historical NFD deliberately preserved). Only WebDAV
// COPY-a-folder-tree-with-rename passes a fresh client string.
let dest_name = dest_name.map(normalize_storage_name_owned);
let row = sqlx::query_as::<_, (String, i64, i64)>(
"SELECT new_root_id, folders_copied, files_copied \
FROM storage.copy_folder_tree($1::uuid, $2::uuid, $3)",
@@ -18,7 +18,7 @@ use crate::common::errors::DomainError;
use crate::domain::entities::folder::Folder;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::authorization::ResourceKind;
use crate::domain::services::path_service::StoragePath;
use crate::domain::services::path_service::{StoragePath, normalize_storage_name_owned};
/// Type alias for folder metadata rows from SQL queries.
/// Tuple order: id, name, path, parent_id, drive_id, created_at,
@@ -261,6 +261,21 @@ impl FolderRepository for FolderDbRepository {
parent_id: Option<String>,
caller_id: Uuid,
) -> Result<Folder, DomainError> {
// Belt-and-suspenders NFC normalization at the last touch before the
// DB write. Every caller that lands here — REST `POST /api/folders`,
// WebDAV `MKCOL`, NextCloud `MKCOL`, batch create, WebDAV/NC `COPY`
// fall-through — passes the raw client-supplied name. macOS Finder /
// Android sync clients emit NFD path segments; if we bind them raw
// the DB row's bytes don't match NFC-normalizing clients' subsequent
// PROPFINDs (see AtalayaLabs/OxiCloud#706). Canonicalising here is
// the single choke-point that closes every entry-point audited on
// 2026-09-03 without asking each handler to remember. Fast-path
// `is_nfc_quick` inside `normalize_storage_name_owned` returns the
// owned string unchanged for names already in NFC — every visible
// ASCII name, every browser-composed non-ASCII name — so this costs
// one table-lookup on the hot path.
let name = normalize_storage_name_owned(name);
// Derive `drive_id` from the parent folder. Root-level folders
// are reserved for the atomic drive-creation transaction in
// `DrivePgRepository::create_personal_drive_atomic` (see
@@ -689,6 +704,11 @@ impl FolderRepository for FolderDbRepository {
new_name: String,
caller_id: Uuid,
) -> Result<Folder, DomainError> {
// NFC-normalize the client-supplied new name — same reasoning as
// `create_folder` above (WebDAV `MOVE`, NC `MOVE`, REST rename all
// funnel here with raw client bytes). Cheap on the common path.
let new_name = normalize_storage_name_owned(new_name);
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
// the AFTER UPDATE cascade trigger then batch-updates all
// descendants in a single UPDATE using the GiST lpath index.
+72 -5
View File
@@ -36,6 +36,7 @@ use crate::application::services::folder_service::FolderService;
use crate::common::di::AppState;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::domain::services::path_service::normalize_storage_name;
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
use crate::infrastructure::services::webdav_dead_property_store::{DeadPropertyStore, ResourceRef};
use crate::interfaces::errors::AppError;
@@ -2452,6 +2453,18 @@ async fn handle_mkcol(
));
}
// Capture the URL-path segments BEFORE scope-resolution rewrites `path`
// to `scope.db_path` — we need the original request URL to reconstruct
// the canonical `Content-Location` when the last segment gets NFC-
// normalized. The last segment is the same either way (it's the target
// resource name), but the URL prefix (including any `@drive/<selector>`
// routing tokens the client used) is only preserved here.
let request_url_segments: Vec<String> = path
.split('/')
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
// RFC 4918 §9.3.1: MKCOL on an existing URL MUST return 405.
// RFC 4918 §9.3.1: MKCOL without an existing parent MUST return 409.
// This handler only creates a single collection (the last path segment).
@@ -2556,8 +2569,15 @@ async fn handle_mkcol(
}
};
// NFC-normalize the client-supplied last segment so we can emit
// `Content-Location` when the canonical URL differs from what the
// client sent. The repo layer normalizes again on the way to the DB
// (idempotently — `is_nfc_quick` returns immediately for already-NFC
// input); doing it here too gives the handler a cheap way to know
// whether the URL changed. See AtalayaLabs/OxiCloud#706.
let normalized_segment = normalize_storage_name(new_segment);
let create_dto = crate::application::dtos::folder_dto::CreateFolderDto {
name: new_segment.to_string(),
name: normalized_segment.clone(),
parent_id,
};
folder_service
@@ -2565,10 +2585,57 @@ async fn handle_mkcol(
.await
.map_err(AppError::from)?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
.unwrap())
// If the client sent an NFD name (macOS Finder, some Android sync
// clients) and we canonicalised it, tell them the authoritative URL
// via `Content-Location` (RFC 7231 §3.1.4.2). Well-behaved clients
// (NextCloud desktop, rclone) update their local index; naive
// clients ignore the header (safely — status stays 201). Emitting
// only when the segment actually changed keeps the wire clean on
// the common ASCII / already-NFC path.
let mut response = Response::builder().status(StatusCode::CREATED);
if normalized_segment != new_segment {
response = response.header(
"Content-Location",
canonical_collection_url(&request_url_segments, &normalized_segment),
);
}
Ok(response.body(Body::empty()).unwrap())
}
/// Reconstruct the canonical `Content-Location` value for a WebDAV
/// resource whose last URL segment was NFC-normalized server-side.
///
/// Takes the original request-URL segments (as split by `/` after the
/// `/webdav/` prefix) and the canonical last-segment string, and
/// returns a full `/webdav/…/` URL with each segment individually
/// percent-encoded. Collection responses append a trailing `/` per
/// RFC 4918 §5.2.
fn canonical_collection_url(request_url_segments: &[String], canonical_last: &str) -> String {
let mut out = String::with_capacity(
request_url_segments.iter().map(|s| s.len()).sum::<usize>() + canonical_last.len() + 16,
);
out.push_str("/webdav/");
// Walk all segments except the last; the last is replaced with the
// canonical (normalized) form.
let prefix = if request_url_segments.len() > 1 {
&request_url_segments[..request_url_segments.len() - 1]
} else {
&[][..]
};
for seg in prefix {
let _ = std::fmt::Write::write_fmt(
&mut out,
format_args!("{}/", utf8_percent_encode(seg, PATH_SEGMENT_ENCODE_SET)),
);
}
let _ = std::fmt::Write::write_fmt(
&mut out,
format_args!(
"{}/",
utf8_percent_encode(canonical_last, PATH_SEGMENT_ENCODE_SET)
),
);
out
}
/**
+28 -5
View File
@@ -27,6 +27,7 @@ use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::di::AppState;
use crate::common::mime_detect::filename_from_path;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::domain::services::path_service::normalize_storage_name;
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
use crate::infrastructure::services::webdav_dead_property_store::ResourceRef;
use crate::interfaces::api::handlers::webdav_handler::{
@@ -1295,6 +1296,13 @@ async fn handle_mkcol(
}
let (target_name, parent_segments) = segments.split_last().expect("checked non-empty above");
// NFC-normalize the client-supplied last segment so we can emit
// `Content-Location` if the canonical URL differs. Repo also
// normalizes (idempotent — `is_nfc_quick` fast path). See
// AtalayaLabs/OxiCloud#706 for the class of bug this closes on the
// NC surface (macOS Finder / NC desktop client emit NFD on macOS).
let normalized_target = normalize_storage_name(target_name);
// Take POC's `chroot`-based root resolution (drive-aware mount
// point) but keep HEAD's parent_path lookup pattern — the
// continuation below uses `get_folder_by_path(&parent_path,
@@ -1320,7 +1328,7 @@ async fn handle_mkcol(
};
let dto = CreateFolderDto {
name: target_name.to_string(),
name: normalized_target.clone(),
parent_id: Some(parent_folder.id.clone()),
};
// AuthZ audit #7 (2026-07-12): route `_with_perms` errors through
@@ -1332,10 +1340,25 @@ async fn handle_mkcol(
.await
.map_err(AppError::from)?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
.unwrap())
// Emit Content-Location (RFC 7231 §3.1.4.2) only when the URL
// canonicalization actually changed something — keeps the common
// ASCII / already-NFC path clean. Well-behaved clients (NC desktop,
// rclone) update their local index; naive clients ignore the
// header safely (status stays 201).
let mut response = Response::builder().status(StatusCode::CREATED);
if normalized_target != *target_name {
let mut canonical_subpath = String::with_capacity(subpath.len());
for seg in parent_segments {
canonical_subpath.push_str(seg);
canonical_subpath.push('/');
}
canonical_subpath.push_str(&normalized_target);
response = response.header(
"Content-Location",
nc_collection_href(&user.username, &canonical_subpath),
);
}
Ok(response.body(Body::empty()).unwrap())
}
// ──────────────────── DELETE ────────────────────