diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 2fbef227..1b226efb 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -114,6 +114,10 @@ jobs: - name: Install npm dependencies run: npm ci + # The case-insensitive filesystem checks should run on macOS (case insensitive file system) + - name: Test (macOS-only filesystem behavior) + run: cargo test -p silverbullet-server-common --all-features + - name: Build server + CLI release archives (darwin x2) run: | make build-server-releases-macos diff --git a/Cargo.lock b/Cargo.lock index e9dbaffe..61cc9f0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2425,6 +2425,7 @@ dependencies = [ "chrono", "filetime", "ignore", + "libc", "mime_guess", "percent-encoding", "reqwest 0.12.28", diff --git a/Cargo.toml b/Cargo.toml index 97981cfe..b4e85239 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ thiserror = "2" argon2 = { version = "0.5", features = ["std"] } walkdir = "2" ignore = "0.4" +libc = "0.2" mime_guess = "2" chrono = "0.4" filetime = "0.2" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d6838b63..e1e43626 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,10 @@ Whenever a commit is pushed to the `main` branch, within ~5 minutes, it will be headless page authorized only its first request and was then redirected to `/.auth`, leaving `/.runtime/*` answering `bridge_unavailable`. It now authenticates with a session cookie for the whole session. ([#2072](https://github.com/silverbulletmd/silverbullet/pull/2072)) +* Fixes around casing in page/file names: + * Renaming a page or folder to a different casing of the same name now works on case-insensitive filesystems (macOS, Windows) + * Renames are now rejected when the new name differs only in casing from an existing page or document, so spaces stay portable between case-sensitive and case-insensitive hosts. + * On case-insensitive filesystems, writing a file whose folder differs only in casing from an existing one now re-cases that folder to match — so writing `notes/foo` when the disk holds `Notes/` renames the folder, changing the reported path of every page inside it. ## 2.10.0 * [[Space Manager]]: multi-space hosting with multiple accounts is here. A fresh install pointed at an empty folder opens a browser-based first-run **setup wizard** that creates an admin account and your first space, then serves it in place with no restart. One server can host any number of [[Space|spaces]], each bound to a URL prefix or hostname. diff --git a/e2e/page-rename.test.ts b/e2e/page-rename.test.ts index 575cc0a5..8fb27c27 100644 --- a/e2e/page-rename.test.ts +++ b/e2e/page-rename.test.ts @@ -68,4 +68,29 @@ test.describe("Top-bar page rename", () => { await sbPage.keyboard.press(`${mod}+k`); await expect(sbPage.locator(".sb-modal-box")).toBeVisible(); }); + + test("rename to a different casing of the same name", async ({ + sbPage, + sbServer, + }) => { + await gotoSilverBulletPage(sbPage, sbServer, "OldName"); + + const nameInput = sbPage.locator("#sb-current-page input.sb-input"); + await nameInput.click(); + await sbPage.keyboard.press(`${mod}+a`); + await sbPage.keyboard.type("oldname"); + await sbPage.keyboard.press("Enter"); + + await sbPage.waitForURL(/\/oldname$/); + + const resp = await fetch(`${sbServer.url}/.fs/oldname.md`); + expect(resp.ok).toBe(true); + expect(await resp.text()).toContain("Content to keep"); + + // Exactly one file, under the new casing. + const listResp = await fetch(`${sbServer.url}/.fs`); + const names = (await listResp.json()).map((f: { name: string }) => f.name); + expect(names).toContain("oldname.md"); + expect(names).not.toContain("OldName.md"); + }); }); diff --git a/plugs/index/refactor.ts b/plugs/index/refactor.ts index ddc8f5b1..0cab109e 100644 --- a/plugs/index/refactor.ts +++ b/plugs/index/refactor.ts @@ -8,6 +8,7 @@ import { } from "@silverbulletmd/silverbullet/syscalls"; import { getTextualBackRelations, type RelationObject } from "./relation.ts"; import { spliceReference } from "./refactor_splice.ts"; +import { findRenameConflict, shouldDeleteOldPath } from "./refactor_case.ts"; import { absoluteToRelativePath, folderName, @@ -137,29 +138,30 @@ export async function batchRenameFiles(fileList: [string, string][]) { }); try { - // Pre-flight checks - await Promise.all( - fileList.map(async ([_oldName, newName]) => { - try { - // It's a FILEname not a PAGEname. - if (!isValidPath(newName)) { - throw new Error(`Name invalid: ${newName}`); - } - // Check if target file already exists - await space.getFileMeta(newName); - // If we got here, the file exists, so we error out - throw new Error( - `${newName} already exists, cannot rename to existing file.`, - ); - } catch (e: any) { - if (e.message === notFoundError.message) { - // Expected not found error, so we can continue - } else { - throw e; - } - } - }), - ); + // Compares against the file list rather than probing with getFileMeta: on + // a case-insensitive filesystem a probe for `oldname.md` resolves the + // existing `OldName.md`, which made every case-only rename impossible. + const existingPaths = [ + ...(await space.listDocuments()).map((doc) => doc.name), + ...(await space.listPages()).map((page) => `${page.name}.md`), + ]; + + for (const [oldName, newName] of fileList) { + // It's a FILEname not a PAGEname. + if (!isValidPath(newName)) { + throw new Error(`Name invalid: ${newName}`); + } + const conflict = findRenameConflict(existingPaths, oldName, newName); + if (conflict === newName) { + throw new Error( + `${newName} already exists, cannot rename to existing file.`, + ); + } else if (conflict !== undefined) { + throw new Error( + `${newName} differs only in casing from ${conflict}, cannot rename.`, + ); + } + } // All new names are available, proceeding with rename for (const [oldName, newName] of fileList) { @@ -186,6 +188,19 @@ export async function batchRenameFiles(fileList: [string, string][]) { } } +/** + * Whether `path` exists under exactly this casing. `getFileMeta` can't answer + * that — on a case-insensitive filesystem it resolves a differently-cased file + * happily — but the file list reports real on-disk names. + */ +async function existsWithExactCasing(path: string): Promise { + if (path.endsWith(".md")) { + const name = path.slice(0, -3); + return (await space.listPages()).some((page) => page.name === name); + } + return (await space.listDocuments()).some((doc) => doc.name === path); +} + // Rename a page, update any backlinks and linked documents async function renamePage(oldName: string, newName: string) { let text = await space.readPage(oldName); @@ -246,7 +261,7 @@ async function renamePage(oldName: string, newName: string) { } // Write the new page - const newPageMeta = await space.writePage(newName, text); + await space.writePage(newName, text); // Move documents along with page const batchRenameDocuments: [string, string][] = []; @@ -261,10 +276,17 @@ async function renamePage(oldName: string, newName: string) { await batchRenameFiles(batchRenameDocuments); } - // Handling the edge case of a changing page name just in casing on a case insensitive FS - const oldPageMeta = await space.getPageMeta(oldName); - if (oldPageMeta.lastModified !== newPageMeta.lastModified) { - // If they're the same, let's assume it's the same file (case insensitive FS) and not delete, otherwise... + // A server-side re-case can fail (a Windows sharing violation, a symlinked + // folder), leaving the file under its old name — where deleting that name + // would destroy the only copy. Only check for case-only renames: the check + // itself is a full uncached `GET /.fs` plus an event storm. + const oldPath = `${oldName}.md`; + const newPath = `${newName}.md`; + const existsExact = + oldPath.toLowerCase() === newPath.toLowerCase() + ? await existsWithExactCasing(newPath) + : false; + if (shouldDeleteOldPath(oldPath, newPath, existsExact)) { await space.deletePage(oldName); } @@ -292,16 +314,18 @@ async function renamePage(oldName: string, newName: string) { async function renameDocument(oldPath: string, newPath: string) { // Move the file const oldFile = await space.readDocument(oldPath); - const newFileMeta = await space.writeDocument(newPath, oldFile); + await space.writeDocument(newPath, oldFile); if ((await editor.getCurrentPath()) === oldPath) { await editor.navigate(newPath, true); } - // Handling the edge case of a changing file name just in casing on a case insensitive FS - const oldFileMeta = await space.getDocumentMeta(oldPath); - if (oldFileMeta.lastModified !== newFileMeta.lastModified) { - // If they're the same, let's assume it's the same file (case insensitive FS) and not delete, otherwise... + // Same guard as renamePage, above. + const existsExact = + oldPath.toLowerCase() === newPath.toLowerCase() + ? await existsWithExactCasing(newPath) + : false; + if (shouldDeleteOldPath(oldPath, newPath, existsExact)) { await space.deleteDocument(oldPath); } diff --git a/plugs/index/refactor_case.test.ts b/plugs/index/refactor_case.test.ts new file mode 100644 index 00000000..c5cdd3a6 --- /dev/null +++ b/plugs/index/refactor_case.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "vitest"; +import { findRenameConflict, shouldDeleteOldPath } from "./refactor_case.ts"; + +const space = ["index.md", "Notes/a.md", "bar.md", "OldName.md"]; + +test("a free target has no conflict", () => { + expect(findRenameConflict(space, "OldName.md", "Fresh.md")).toBeUndefined(); +}); + +test("an exact match is reported so the caller can say 'already exists'", () => { + expect(findRenameConflict(space, "OldName.md", "bar.md")).toBe("bar.md"); +}); + +test("a case-insensitive near-match is reported as a collision", () => { + expect(findRenameConflict(space, "OldName.md", "Bar.md")).toBe("bar.md"); +}); + +test("a file never conflicts with its own old path", () => { + expect(findRenameConflict(space, "OldName.md", "oldname.md")).toBeUndefined(); +}); + +test("a folder case change does not conflict with itself", () => { + expect(findRenameConflict(space, "Notes/a.md", "notes/a.md")).toBeUndefined(); +}); + +test("a folder case change still collides with an unrelated file", () => { + expect(findRenameConflict([...space, "notes/b.md"], "Notes/a.md", "notes/B.md")) + .toBe("notes/b.md"); +}); + +const dup = ["Foo.md", "foo.md"]; + +test("a pre-existing case-variant of a different file is still a real collision", () => { + expect(findRenameConflict(dup, "Foo.md", "foo.md")).toBe("foo.md"); +}); + +test("case-only rename deletes the old path once the new casing landed", () => { + expect(shouldDeleteOldPath("OldName.md", "oldname.md", true)).toBe(true); +}); + +test("case-only rename skips the delete when the new casing didn't land", () => { + expect(shouldDeleteOldPath("OldName.md", "oldname.md", false)).toBe(false); +}); + +test("a genuine rename deletes the old path regardless of the flag (true)", () => { + expect(shouldDeleteOldPath("OldName.md", "NewName.md", true)).toBe(true); +}); + +test("a genuine rename deletes the old path regardless of the flag (false)", () => { + expect(shouldDeleteOldPath("OldName.md", "NewName.md", false)).toBe(true); +}); diff --git a/plugs/index/refactor_case.ts b/plugs/index/refactor_case.ts new file mode 100644 index 00000000..bd0051d3 --- /dev/null +++ b/plugs/index/refactor_case.ts @@ -0,0 +1,38 @@ +/** + * Finds an existing path that blocks renaming `oldPath` to `newPath`, or + * undefined when the rename may proceed. + * + * Comparison is case-insensitive so renames can't collide on a case-insensitive + * filesystem; excluding the file's own old path is what permits renaming a file + * to a different casing of its own name. A result equal to `newPath` means the + * target genuinely exists, anything else means it differs only in casing. + */ +export function findRenameConflict( + existingPaths: string[], + oldPath: string, + newPath: string, +): string | undefined { + const target = newPath.toLowerCase(); + return existingPaths.find( + (path) => path !== oldPath && path.toLowerCase() === target, + ); +} + +/** + * Whether the old path may be deleted after the new one was written. + * + * A server-side re-case can fail (a Windows sharing violation, a symlinked + * folder), leaving the file under its old name — where deleting that name would + * destroy the only copy. Names differing by more than casing are distinct files + * and need no check. + */ +export function shouldDeleteOldPath( + oldPath: string, + newPath: string, + newPathExistsWithExactCasing: boolean, +): boolean { + if (oldPath.toLowerCase() !== newPath.toLowerCase()) { + return true; + } + return newPathExistsWithExactCasing; +} diff --git a/server-common/Cargo.toml b/server-common/Cargo.toml index 6dc91742..58b261d5 100644 --- a/server-common/Cargo.toml +++ b/server-common/Cargo.toml @@ -29,5 +29,12 @@ tracing = { workspace = true } zip = { workspace = true } tempfile = { workspace = true } +# `fcntl(F_GETPATH)` is the only cheap way to recover true on-disk casing on +# macOS; `canonicalize` does not correct casing there. Windows gets the same +# information from `std::fs::canonicalize` (which uses +# GetFinalPathNameByHandle), so no dependency is needed there. +[target.'cfg(target_os = "macos")'.dependencies] +libc = { workspace = true } + [lints] workspace = true diff --git a/server-common/src/space.rs b/server-common/src/space.rs index 7c63f38b..347296e9 100644 --- a/server-common/src/space.rs +++ b/server-common/src/space.rs @@ -1,5 +1,6 @@ //! `SpacePrimitives` implementations and composition wrappers. +pub mod case; pub mod disk; pub mod embed; pub mod http; diff --git a/server-common/src/space/case.rs b/server-common/src/space/case.rs new file mode 100644 index 00000000..0c696c3f --- /dev/null +++ b/server-common/src/space/case.rs @@ -0,0 +1,552 @@ +//! Case-resolution helpers for `DiskSpacePrimitives`. +//! +//! On a case-insensitive filesystem, writing to `notes/a.md` when the disk +//! holds `Notes/A.md` truncates that entry but keeps its old name — so a +//! case-only rename can never take effect. These helpers recover the true +//! on-disk casing and re-case entries to match what the caller asked for. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Keeps concurrent probes on the same root from colliding. +static PROBE_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Detect whether `root` lives on a case-insensitive filesystem. +/// +/// The probe file is dot-prefixed because `fetch_file_list` and the client's +/// `CheckedSpacePrimitives` both skip those, so it can't surface to a user even +/// if cleanup fails. Any failure reports `false`, keeping the backend on its +/// historical behavior. +pub(crate) fn detect_case_insensitive(root: &Path) -> bool { + let n = PROBE_COUNTER.fetch_add(1, Ordering::Relaxed); + let lower = format!(".sb-case-probe-{}-{}", std::process::id(), n); + let upper = lower.to_uppercase(); + + let probe = root.join(&lower); + if std::fs::write(&probe, b"").is_err() { + return false; + } + let insensitive = std::fs::symlink_metadata(root.join(&upper)).is_ok(); + let _ = std::fs::remove_file(&probe); + insensitive +} + +/// Plan the renames turning the on-disk casing (`actual_rel`) into the +/// requested one (`desired_rel`), both relative to `root`. +/// +/// Returns absolute `(from, to)` pairs, outermost first. Each pair assumes +/// every earlier pair already landed, so a caller hitting an error must stop +/// rather than skip ahead. +pub(crate) fn plan_recase( + root: &Path, + actual_rel: &Path, + desired_rel: &Path, +) -> Vec<(PathBuf, PathBuf)> { + let actual: Vec<_> = actual_rel.components().map(|c| c.as_os_str()).collect(); + let desired: Vec<_> = desired_rel.components().map(|c| c.as_os_str()).collect(); + + // Shapes must line up; anything else is a caller bug, and doing nothing is + // the safe response. + if actual.len() != desired.len() { + return Vec::new(); + } + + let mut plan = Vec::new(); + for i in 0..actual.len() { + if actual[i] == desired[i] { + continue; + } + // Everything shallower than `i` has already been renamed to the + // desired casing by the time this pair runs. + let mut from = root.to_path_buf(); + for d in desired.iter().take(i) { + from.push(d); + } + from.push(actual[i]); + + let mut to = root.to_path_buf(); + for d in desired.iter().take(i + 1) { + to.push(d); + } + plan.push((from, to)); + } + plan +} + +/// The case-exact path of an existing entry, relative to `root`, or `None`. +/// +/// `rel` must already have been validated by `safe_path`, and `root` must be +/// canonicalized — the containment check below compares against a +/// symlink-resolved path, so an uncanonicalized root (`/var/...` rather than +/// `/private/var/...`) makes every lookup report `None`. +pub(crate) fn true_relative_path(root: &Path, rel: &Path) -> std::io::Result> { + let depth = rel.components().count(); + if depth == 0 { + return Ok(None); + } + let Some(abs) = resolve_true_absolute(root, rel)? else { + return Ok(None); + }; + + let components: Vec<_> = abs.components().collect(); + if components.len() >= depth { + let prefix: PathBuf = components[..components.len() - depth].iter().collect(); + if paths_equal_ignoring_case(&prefix, root) { + return Ok(Some(trailing_components(&abs, depth))); + } + } + + // `F_GETPATH` and `canonicalize` dereference symlinks, so a page inside a + // folder symlinked into the space resolves to a path outside it. Those are + // real space files and must stay re-casable, so recover the casing by + // matching directory entry names instead — that walk never leaves the space. + walk_true_relative(root, rel) +} + +/// The root's stored casing may differ from its true on-disk casing, so a +/// containment check has to ignore case. +fn paths_equal_ignoring_case(a: &Path, b: &Path) -> bool { + a.to_string_lossy().to_lowercase() == b.to_string_lossy().to_lowercase() +} + +/// Drops the space root (whose casing we must never touch) from a resolved +/// absolute path. `strip_prefix` can't do this: it fails when the root's true +/// casing differs from the one we hold. +fn trailing_components(path: &Path, n: usize) -> PathBuf { + let comps: Vec<_> = path.components().map(|c| c.as_os_str()).collect(); + let start = comps.len().saturating_sub(n); + let mut out = PathBuf::new(); + for c in &comps[start..] { + out.push(c); + } + out +} + +#[cfg(target_os = "macos")] +fn resolve_true_absolute(root: &Path, rel: &Path) -> std::io::Result> { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + use std::os::unix::io::AsRawFd; + + // Opening a directory read-only is allowed on Unix, so this covers folders + // as well as files. + let file = match std::fs::File::open(root.join(rel)) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e), + }; + + let mut buf = vec![0u8; libc::PATH_MAX as usize]; + // SAFETY: `buf` is PATH_MAX bytes, which is what F_GETPATH requires, and + // the fd is owned by `file` for the duration of the call. + let rc = unsafe { + libc::fcntl( + file.as_raw_fd(), + libc::F_GETPATH, + buf.as_mut_ptr() as *mut libc::c_char, + ) + }; + if rc < 0 { + return Err(std::io::Error::last_os_error()); + } + let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + buf.truncate(len); + Ok(Some(PathBuf::from(OsString::from_vec(buf)))) +} + +#[cfg(windows)] +fn resolve_true_absolute(root: &Path, rel: &Path) -> std::io::Result> { + // On Windows `canonicalize` goes through GetFinalPathNameByHandle, which + // reports true casing. The `\\?\` prefix it returns is harmless here + // because the caller only keeps the trailing components. + match std::fs::canonicalize(root.join(rel)) { + Ok(p) => Ok(Some(p)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +/// Portable fallback. Only reachable on a case-insensitive mount on a +/// non-Mac/Windows host (CIFS, ext4 casefold), so the per-component `read_dir` +/// cost stays confined to the setups that need it. +#[cfg(not(any(target_os = "macos", windows)))] +fn resolve_true_absolute(root: &Path, rel: &Path) -> std::io::Result> { + if let Err(e) = std::fs::symlink_metadata(root.join(rel)) { + if e.kind() == std::io::ErrorKind::NotFound { + return Ok(None); + } + return Err(e); + } + Ok(walk_true_relative(root, rel)?.map(|r| root.join(r))) +} + +fn find_entry(dir: &Path, name: &std::ffi::OsStr) -> std::io::Result> { + let wanted = name.to_string_lossy(); + let mut case_insensitive_match = None; + for entry in std::fs::read_dir(dir)? { + let found = entry?.file_name(); + if found == name { + return Ok(Some(found)); // exact match always wins + } + if found.to_string_lossy().eq_ignore_ascii_case(&wanted) { + case_insensitive_match = Some(found); + } + } + Ok(case_insensitive_match) +} + +fn walk_true_relative(root: &Path, rel: &Path) -> std::io::Result> { + let mut dir = root.to_path_buf(); + let mut resolved = PathBuf::new(); + for comp in rel.components() { + match find_entry(&dir, comp.as_os_str())? { + Some(real) => { + dir.push(&real); + resolved.push(&real); + } + None => return Ok(None), + } + } + Ok(Some(resolved)) +} + +/// Apply a plan from `plan_recase`, best-effort. +/// +/// Never surfaces an error: if a rename fails the caller's write still lands +/// through the case-insensitive alias, which is the historical behavior. +/// Failures are expected in the wild — on Windows a directory rename fails +/// while antivirus or a search indexer holds a handle inside it. +/// +/// Stops at the first problem rather than skipping ahead, since every later +/// pair assumes the earlier renames landed. +pub(crate) fn apply_recase(plan: &[(PathBuf, PathBuf)]) { + for (from, to) in plan { + // Fires when a plan pair's `from` is itself the symlinked entry: + // renaming the link rather than what it points at is not ours to do. + // Renames *through* a symlinked directory pass this check and should — + // an external folder symlinked into a space holds real space files, and + // renaming one is exactly what the user asked for. + // + // Reachable on every platform. On macOS and Windows it is the fallback + // walk in `true_relative_path` that produces such a pair, since the OS + // resolver's own answer never survives the containment check. + match std::fs::symlink_metadata(from) { + Ok(md) if md.file_type().is_symlink() => { + tracing::debug!( + "not re-casing {}: symlinked components may point outside the space", + from.display() + ); + return; + } + Ok(_) => {} + Err(e) => { + tracing::debug!("not re-casing {}: {}", from.display(), e); + return; + } + } + if let Err(e) = std::fs::rename(from, to) { + tracing::debug!( + "could not re-case {} to {}: {} — writing through the existing name", + from.display(), + to.display(), + e + ); + return; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + /// The probe must agree with what the filesystem actually does, on every + /// platform. This is the test that gives the probe meaning on Linux CI + /// (where it must report false) and on a Mac (where it must report true). + #[test] + fn probe_matches_filesystem_behavior() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("CaseProbeControl.md"), b"x").unwrap(); + let control = std::fs::symlink_metadata(dir.path().join("caseprobecontrol.md")).is_ok(); + + assert_eq!(detect_case_insensitive(dir.path()), control); + } + + #[test] + fn probe_leaves_no_files_behind() { + let dir = tempdir().unwrap(); + detect_case_insensitive(dir.path()); + + let leftovers: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert!( + leftovers.is_empty(), + "probe left files behind: {leftovers:?}" + ); + } + + #[test] + fn probe_on_unwritable_root_reports_case_sensitive() { + let dir = tempdir().unwrap(); + let sub = dir.path().join("nope"); + // Never created — writing into it fails, which must degrade to false + // rather than panicking. + assert!(!detect_case_insensitive(&sub)); + } + + fn plan(root: &str, actual: &str, desired: &str) -> Vec<(String, String)> { + plan_recase(Path::new(root), Path::new(actual), Path::new(desired)) + .into_iter() + .map(|(f, t)| { + ( + f.to_string_lossy().into_owned(), + t.to_string_lossy().into_owned(), + ) + }) + .collect() + } + + #[test] + fn identical_paths_need_no_renames() { + assert!(plan("/s", "Notes/A.md", "Notes/A.md").is_empty()); + } + + #[test] + fn file_component_only() { + assert_eq!( + plan("/s", "Notes/OldName.md", "Notes/oldname.md"), + vec![( + "/s/Notes/OldName.md".to_string(), + "/s/Notes/oldname.md".to_string() + )] + ); + } + + #[test] + fn folder_component_only() { + assert_eq!( + plan("/s", "Notes/a.md", "notes/a.md"), + vec![("/s/Notes".to_string(), "/s/notes".to_string())] + ); + } + + /// Outermost first, and each later pair is expressed in terms of the + /// *already renamed* prefix — otherwise the second rename would target a + /// path that no longer exists. + #[test] + fn every_differing_component_outermost_first() { + assert_eq!( + plan("/s", "Notes/Sub/A.md", "notes/sub/a.md"), + vec![ + ("/s/Notes".to_string(), "/s/notes".to_string()), + ("/s/notes/Sub".to_string(), "/s/notes/sub".to_string()), + ( + "/s/notes/sub/A.md".to_string(), + "/s/notes/sub/a.md".to_string() + ), + ] + ); + } + + #[test] + fn matching_components_are_left_alone() { + assert_eq!( + plan("/s", "Notes/Sub/A.md", "Notes/sub/A.md"), + vec![("/s/Notes/Sub".to_string(), "/s/Notes/sub".to_string())] + ); + } + + /// Defensive: a caller that hands us paths of different shapes gets no + /// renames rather than a garbage sequence. + #[test] + fn mismatched_depth_plans_nothing() { + assert!(plan("/s", "Notes/a.md", "a.md").is_empty()); + } + + #[test] + fn exact_case_resolves_to_itself() { + let dir = tempdir().unwrap(); + // `true_relative_path` requires an already-canonical root (its real + // caller does this at construction); on macOS `tempdir()` returns a + // path under `/var`, itself a symlink to `/private/var`, which would + // otherwise make the new containment check reject everything. + let root = dir.path().canonicalize().unwrap(); + std::fs::create_dir_all(root.join("Notes")).unwrap(); + std::fs::write(root.join("Notes/A.md"), b"x").unwrap(); + + let got = true_relative_path(&root, Path::new("Notes/A.md")).unwrap(); + assert_eq!(got, Some(PathBuf::from("Notes/A.md"))); + } + + #[test] + fn missing_path_resolves_to_none() { + let dir = tempdir().unwrap(); + let got = true_relative_path(dir.path(), Path::new("Notes/nope.md")).unwrap(); + assert_eq!(got, None); + } + + /// The whole point: asking with the wrong casing reports the *real* casing + /// of every component. Gated to macOS — on Linux there is nothing to + /// reveal, and a test that runs but asserts nothing is worse than absent. + #[cfg(target_os = "macos")] + #[test] + fn wrong_case_reveals_true_casing() { + let dir = tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + if !detect_case_insensitive(&root) { + return; // rare case-sensitive APFS volume + } + std::fs::create_dir_all(root.join("Notes/Sub")).unwrap(); + std::fs::write(root.join("Notes/Sub/MixedCase.md"), b"x").unwrap(); + + let got = true_relative_path(&root, Path::new("notes/sub/mixedcase.md")).unwrap(); + assert_eq!(got, Some(PathBuf::from("Notes/Sub/MixedCase.md"))); + } + + /// `F_GETPATH` resolves symlinks all the way through, so a component that + /// is a symlink to something outside the space resolves to a path that is + /// textually outside the root. That must not leak as a relative path built + /// from those outside components (e.g. `deep/A.md`) — recovering by name + /// walk must still land on the in-space path through the symlink. + #[cfg(target_os = "macos")] + #[test] + fn symlinked_component_resolves_inside_the_space() { + let space_dir = tempdir().unwrap(); + let space = space_dir.path().canonicalize().unwrap(); + if !detect_case_insensitive(&space) { + return; // rare case-sensitive APFS volume + } + let outside = tempdir().unwrap(); + std::fs::write(outside.path().join("A.md"), b"x").unwrap(); + std::os::unix::fs::symlink(outside.path(), space.join("Linked")).unwrap(); + + let got = true_relative_path(&space, Path::new("Linked/A.md")).unwrap(); + assert_eq!(got, Some(PathBuf::from("Linked/A.md"))); + } + + /// A page inside a symlinked-in folder is a real space file, so its casing + /// must be recoverable even though the OS resolver reports a path outside + /// the space. + #[cfg(target_os = "macos")] + #[test] + fn symlinked_folder_contents_resolve_by_walking() { + let dir = tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + if !detect_case_insensitive(&root) { + return; // rare case-sensitive APFS volume + } + let outside = tempdir().unwrap(); + std::fs::write(outside.path().join("Sub.md"), b"x").unwrap(); + std::os::unix::fs::symlink(outside.path(), root.join("Linked")).unwrap(); + + let got = true_relative_path(&root, Path::new("Linked/sub.md")).unwrap(); + assert_eq!(got, Some(PathBuf::from("Linked/Sub.md"))); + } + + #[test] + fn applies_renames_in_order() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("Notes/Sub")).unwrap(); + std::fs::write(dir.path().join("Notes/Sub/A.md"), b"body").unwrap(); + + apply_recase(&plan_recase( + dir.path(), + Path::new("Notes/Sub/A.md"), + Path::new("notes/sub/a.md"), + )); + + assert_eq!( + std::fs::read(dir.path().join("notes/sub/a.md")).unwrap(), + b"body" + ); + let top: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(top, vec!["notes".to_string()]); + } + + /// A symlinked component may point outside the space entirely; renaming it + /// is out of bounds, so the whole plan stops there. + #[cfg(unix)] + #[test] + fn stops_at_a_symlinked_component() { + let dir = tempdir().unwrap(); + let outside = tempdir().unwrap(); + std::fs::write(outside.path().join("A.md"), b"body").unwrap(); + std::os::unix::fs::symlink(outside.path(), dir.path().join("Linked")).unwrap(); + + apply_recase(&plan_recase( + dir.path(), + Path::new("Linked/A.md"), + Path::new("linked/a.md"), + )); + + // Reads the literal directory entry name rather than doing a + // case-insensitive path lookup (`.join("Linked").is_symlink()`) — + // on default APFS that lookup would still resolve to a renamed + // `linked` entry and pass either way, masking a regression. + let entries: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(entries, vec!["Linked".to_string()]); + assert!(outside.path().join("A.md").exists()); + } + + #[test] + fn missing_source_is_a_no_op() { + let dir = tempdir().unwrap(); + apply_recase(&plan_recase( + dir.path(), + Path::new("Gone.md"), + Path::new("gone.md"), + )); + assert!(!dir.path().join("gone.md").exists()); + } + + #[test] + fn stops_after_a_missing_source_instead_of_skipping_ahead() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("Second.md"), b"body").unwrap(); + + apply_recase(&[ + (dir.path().join("Missing.md"), dir.path().join("missing.md")), + (dir.path().join("Second.md"), dir.path().join("second.md")), + ]); + + let entries: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(entries, vec!["Second.md".to_string()]); + } + + #[test] + fn stops_after_a_failed_rename_not_just_a_missing_source() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("First.md"), b"one").unwrap(); + std::fs::write(dir.path().join("Second.md"), b"two").unwrap(); + + apply_recase(&[ + ( + dir.path().join("First.md"), + dir.path().join("no-such-dir/first.md"), + ), + (dir.path().join("Second.md"), dir.path().join("second.md")), + ]); + + let mut entries: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + entries.sort(); + assert_eq!( + entries, + vec!["First.md".to_string(), "Second.md".to_string()] + ); + } +} diff --git a/server-common/src/space/disk.rs b/server-common/src/space/disk.rs index 54a0d3d8..3e466a3e 100644 --- a/server-common/src/space/disk.rs +++ b/server-common/src/space/disk.rs @@ -5,12 +5,16 @@ use std::time::{SystemTime, UNIX_EPOCH}; use ignore::gitignore::GitignoreBuilder; use walkdir::WalkDir; +use crate::space::case; use crate::types::{FileMeta, SpaceError, SpacePrimitives}; /// Filesystem-backed SpacePrimitives over a space folder on disk. pub struct DiskSpacePrimitives { root_path: PathBuf, gitignore_patterns: String, + /// Probed once at construction. When false, every case rule below is + /// skipped and the backend behaves exactly as it always has. + case_insensitive: bool, } impl DiskSpacePrimitives { @@ -30,6 +34,7 @@ impl DiskSpacePrimitives { } Ok(Self { + case_insensitive: case::detect_case_insensitive(&root), root_path: root, gitignore_patterns: gitignore.to_string(), }) @@ -61,6 +66,43 @@ impl DiskSpacePrimitives { Ok(self.root_path.join(clean)) } + pub fn is_case_insensitive(&self) -> bool { + self.case_insensitive + } + + /// Lets tests exercise the case-sensitive path on a case-insensitive host. + #[cfg(test)] + pub(crate) fn force_case_insensitive(&mut self, value: bool) { + self.case_insensitive = value; + } + + /// Re-case an aliased on-disk entry to match the requested casing. Inert on + /// case-sensitive filesystems, where a differently cased path simply + /// doesn't resolve and creating a separate entry is correct. + fn recase_to_requested(&self, path: &str) { + if !self.case_insensitive { + return; + } + let rel = Path::new(path); + if let Ok(Some(actual)) = case::true_relative_path(&self.root_path, rel) { + if actual.as_path() != rel { + case::apply_recase(&case::plan_recase(&self.root_path, &actual, rel)); + } + return; + } + // The leaf doesn't exist yet: a plain create, or the create half of a + // rename whose delete already ran. The parent chain can still be + // aliased, and without this the write lands inside the old-cased folder. + let Some(parent) = rel.parent().filter(|p| !p.as_os_str().is_empty()) else { + return; + }; + if let Ok(Some(actual)) = case::true_relative_path(&self.root_path, parent) { + if actual.as_path() != parent { + case::apply_recase(&case::plan_recase(&self.root_path, &actual, parent)); + } + } + } + /// Convert an absolute path back to a relative forward-slash path. fn path_to_filename(&self, full_path: &Path) -> String { let rel = full_path.strip_prefix(&self.root_path).unwrap_or(full_path); @@ -241,6 +283,10 @@ impl SpacePrimitives for DiskSpacePrimitives { ) -> Result { let local_path = self.safe_path(path)?; + // Without this, a case-insensitive filesystem truncates the aliased + // entry but keeps its old name, so the rename never lands. + self.recase_to_requested(path); + // Ensure parent directory exists if let Some(parent) = local_path.parent() { fs::create_dir_all(parent) @@ -266,6 +312,27 @@ impl SpacePrimitives for DiskSpacePrimitives { fn delete_file(&self, path: &str) -> Result<(), SpaceError> { let local_path = self.safe_path(path)?; + + // The sync engine emits create and delete as independent, unordered + // operations, so without this guard a delete addressed to a stale + // casing destroys the file that now lives under the new casing. + // + // Reports success rather than NotFound on purpose: the client's + // `deleteFile` throws on any non-200 and `syncFile` calls it unguarded, + // so a 404 here would abort the whole sync round. + if self.case_insensitive { + let rel = Path::new(path); + match case::true_relative_path(&self.root_path, rel) { + Ok(Some(actual)) if actual.as_path() != rel => return Ok(()), + Ok(_) => {} + // Falling through to a real delete here would unlink whatever + // this path aliases, which may be the file a paired write just + // re-cased. A failed sync round is recoverable; a lost page is + // not. + Err(e) => return Err(SpaceError::Io(e)), + } + } + fs::remove_file(&local_path).map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { SpaceError::NotFound @@ -362,6 +429,232 @@ mod plan_tests { let sp = DiskSpacePrimitives::new(dir.path(), "").unwrap(); crate::space::conformance::run_read_write_conformance(&sp); } + + fn names(sp: &DiskSpacePrimitives) -> Vec { + let mut v: Vec = sp + .fetch_file_list() + .unwrap() + .into_iter() + .map(|m| m.name) + .collect(); + v.sort(); + v + } + + #[test] + fn write_leaves_an_exact_match_alone() { + let dir = tempdir().unwrap(); + let sp = DiskSpacePrimitives::new(dir.path(), "").unwrap(); + + sp.write_file("Notes/A.md", b"one", None).unwrap(); + sp.write_file("Notes/A.md", b"two", None).unwrap(); + + assert_eq!(names(&sp), vec!["Notes/A.md".to_string()]); + assert_eq!(sp.read_file("Notes/A.md").unwrap().0, b"two"); + } + + /// With the flag off, the backend must behave exactly as it did before this + /// change — this is what protects Linux servers. + #[test] + fn write_does_nothing_special_when_case_sensitive() { + let dir = tempdir().unwrap(); + let mut sp = DiskSpacePrimitives::new(dir.path(), "").unwrap(); + sp.force_case_insensitive(false); + + sp.write_file("OldName.md", b"body", None).unwrap(); + sp.write_file("oldname.md", b"body", None).unwrap(); + + let mut found = names(&sp); + found.sort(); + if sp_fs_is_case_insensitive(dir.path()) { + // Historical behavior: the alias truncates and the name survives. + assert_eq!(found, vec!["OldName.md".to_string()]); + } else { + assert_eq!( + found, + vec!["OldName.md".to_string(), "oldname.md".to_string()] + ); + } + } + + fn sp_fs_is_case_insensitive(root: &std::path::Path) -> bool { + crate::space::case::detect_case_insensitive(root) + } + + #[test] + fn delete_still_removes_an_exact_match() { + let dir = tempdir().unwrap(); + let sp = DiskSpacePrimitives::new(dir.path(), "").unwrap(); + + sp.write_file("Notes/A.md", b"body", None).unwrap(); + sp.delete_file("Notes/A.md").unwrap(); + + assert!(names(&sp).is_empty()); + } + + /// A genuinely missing file must still report NotFound — only a *case + /// mismatch* is silently ignored. + #[test] + fn delete_of_missing_file_still_reports_not_found() { + let dir = tempdir().unwrap(); + let sp = DiskSpacePrimitives::new(dir.path(), "").unwrap(); + + assert!(matches!( + sp.delete_file("nope.md"), + Err(crate::types::SpaceError::NotFound) + )); + } + + /// Tests needing a filesystem that actually aliases casings. Gated to macOS + /// so they are absent on Linux rather than present and vacuous. + /// + /// A case-sensitive APFS volume is possible but opt-in at format time; the + /// `is_case_insensitive` guard covers that rare case. + #[cfg(target_os = "macos")] + mod fs_tests { + use super::*; + + #[test] + fn write_recases_an_aliased_file() { + let dir = tempdir().unwrap(); + let sp = DiskSpacePrimitives::new(dir.path(), "").unwrap(); + if !sp.is_case_insensitive() { + return; + } + + sp.write_file("OldName.md", b"body", None).unwrap(); + sp.write_file("oldname.md", b"body", None).unwrap(); + + assert_eq!(names(&sp), vec!["oldname.md".to_string()]); + } + + #[test] + fn write_recases_an_aliased_folder() { + let dir = tempdir().unwrap(); + let sp = DiskSpacePrimitives::new(dir.path(), "").unwrap(); + if !sp.is_case_insensitive() { + return; + } + + sp.write_file("Notes/a.md", b"body", None).unwrap(); + sp.write_file("notes/a.md", b"body", None).unwrap(); + + assert_eq!(names(&sp), vec!["notes/a.md".to_string()]); + } + + /// A re-case that can't be applied must never fail the user's save — + /// the write still lands through the case-insensitive alias. + #[test] + fn write_succeeds_when_recasing_is_skipped() { + let dir = tempdir().unwrap(); + let outside = tempdir().unwrap(); + std::os::unix::fs::symlink(outside.path(), dir.path().join("Linked")).unwrap(); + let sp = DiskSpacePrimitives::new(dir.path(), "").unwrap(); + if !sp.is_case_insensitive() { + return; + } + sp.write_file("Linked/a.md", b"one", None).unwrap(); + + sp.write_file("linked/a.md", b"two", None).unwrap(); + + // Reads the literal directory entry name rather than doing a + // case-insensitive path lookup (`.join("Linked").is_symlink()`) — + // on default APFS that lookup would still resolve to a renamed + // `linked` entry and pass either way, masking a regression. + let entries: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(entries, vec!["Linked".to_string()]); + assert_eq!(sp.read_file("linked/a.md").unwrap().0, b"two"); + } + + /// After a re-case, a delete addressed to the *old* casing must not + /// touch the file — this is what stops the sync engine's unordered + /// create/delete pair from destroying the page. + #[test] + fn delete_ignores_a_stale_casing() { + let dir = tempdir().unwrap(); + let sp = DiskSpacePrimitives::new(dir.path(), "").unwrap(); + if !sp.is_case_insensitive() { + return; + } + + sp.write_file("OldName.md", b"body", None).unwrap(); + sp.write_file("oldname.md", b"body", None).unwrap(); + + sp.delete_file("OldName.md").unwrap(); + + assert_eq!(names(&sp), vec!["oldname.md".to_string()]); + assert_eq!(sp.read_file("oldname.md").unwrap().0, b"body"); + } + + /// The sync engine iterates the union of changed paths with no ordering + /// guarantee, so a case-only rename reaches the server as an unordered + /// create/delete pair. Both orders must end with exactly one file, + /// under the new casing, with the right content. + #[test] + fn sync_pair_converges_create_then_delete() { + let dir = tempdir().unwrap(); + let sp = DiskSpacePrimitives::new(dir.path(), "").unwrap(); + if !sp.is_case_insensitive() { + return; + } + sp.write_file("OldName.md", b"body", None).unwrap(); + + sp.write_file("oldname.md", b"body", None).unwrap(); + sp.delete_file("OldName.md").unwrap(); + + assert_eq!(names(&sp), vec!["oldname.md".to_string()]); + assert_eq!(sp.read_file("oldname.md").unwrap().0, b"body"); + } + + #[test] + fn sync_pair_converges_delete_then_create() { + let dir = tempdir().unwrap(); + let sp = DiskSpacePrimitives::new(dir.path(), "").unwrap(); + if !sp.is_case_insensitive() { + return; + } + sp.write_file("OldName.md", b"body", None).unwrap(); + + sp.delete_file("OldName.md").unwrap(); + sp.write_file("oldname.md", b"body", None).unwrap(); + + assert_eq!(names(&sp), vec!["oldname.md".to_string()]); + assert_eq!(sp.read_file("oldname.md").unwrap().0, b"body"); + } + + /// Same property one level up, which is where the whole-path comparison + /// matters: the file component matches exactly, only the folder differs. + #[test] + fn folder_sync_pair_converges_in_both_orders() { + for delete_first in [false, true] { + let dir = tempdir().unwrap(); + let sp = DiskSpacePrimitives::new(dir.path(), "").unwrap(); + if !sp.is_case_insensitive() { + return; + } + sp.write_file("Notes/a.md", b"body", None).unwrap(); + sp.write_file("Notes/keep.md", b"keep", None).unwrap(); + + if delete_first { + sp.delete_file("Notes/a.md").unwrap(); + sp.write_file("notes/a.md", b"body", None).unwrap(); + } else { + sp.write_file("notes/a.md", b"body", None).unwrap(); + sp.delete_file("Notes/a.md").unwrap(); + } + + assert_eq!( + names(&sp), + vec!["notes/a.md".to_string(), "notes/keep.md".to_string()], + "delete_first={delete_first}" + ); + assert_eq!(sp.read_file("notes/a.md").unwrap().0, b"body"); + } + } + } } /// Tree-walk behavior around symlinks and unreadable directories. Unix-only: