fix(filename): fix uniform encoding encoding (NFC)
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user