From daa67bea51b66356867209d49049054ece149967 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 5 Nov 2026 10:35:41 +0000 Subject: [PATCH] Fix CLI filesystem case-sensitivity detection --- src/apps/cli/main.bootstrap.unit.spec.ts | 38 +++++++++ src/apps/cli/package.json | 2 +- src/apps/cli/services/NodeServiceHub.ts | 5 ++ .../cli/services/storageCaseSensitivity.ts | 14 ++++ .../storageCaseSensitivity.unit.spec.ts | 83 +++++++++++++++++++ updates.md | 2 + 6 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/apps/cli/services/storageCaseSensitivity.ts create mode 100644 src/apps/cli/services/storageCaseSensitivity.unit.spec.ts diff --git a/src/apps/cli/main.bootstrap.unit.spec.ts b/src/apps/cli/main.bootstrap.unit.spec.ts index e33804a0..631ba323 100644 --- a/src/apps/cli/main.bootstrap.unit.spec.ts +++ b/src/apps/cli/main.bootstrap.unit.spec.ts @@ -103,6 +103,44 @@ describe("CLI database preparation", () => { await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`); } + it.each([ + { handleFilenameCaseSensitive: true, pathNormalizationVersion: 0 as const }, + { handleFilenameCaseSensitive: true, pathNormalizationVersion: 1 as const }, + { handleFilenameCaseSensitive: false, pathNormalizationVersion: 0 as const }, + { handleFilenameCaseSensitive: false, pathNormalizationVersion: 1 as const }, + ])( + "writes Vault files with case sensitivity $handleFilenameCaseSensitive and path identity $pathNormalizationVersion", + async (settings) => { + await writeFile( + settingsPath, + JSON.stringify({ ...createDefaultCliSettings(), ...settings, isConfigured: true }) + ); + const writes: boolean[] = []; + let failure: unknown; + const runner = vi.fn(async (_options, { core }) => { + try { + writes.push( + await core.serviceModules.storageAccess.writeFileAuto("notes/Remote.md", "remote content") + ); + writes.push( + await core.serviceModules.storageAccess.writeFileAuto("notes/Remote.md", "updated content") + ); + } catch (error) { + failure = error; + } + return false; + }); + + await start("ls", runner); + + expect(runner).toHaveBeenCalledOnce(); + expect(failure).toBeUndefined(); + expect(writes).toEqual([true, true]); + expect(await readFile(join(vaultPath, "notes/Remote.md"), "utf8")).toBe("updated content"); + expect(await readFile(join(vaultPath, "notes/local.md"), "utf8")).toBe("local content"); + } + ); + it.each([ { separator: "/", platform: "POSIX" }, { separator: "\\", platform: "Windows" }, diff --git a/src/apps/cli/package.json b/src/apps/cli/package.json index eedb6d37..cc9a648f 100644 --- a/src/apps/cli/package.json +++ b/src/apps/cli/package.json @@ -12,7 +12,7 @@ "buildRun": "npm run build && npm run cli --", "build:docker": "docker build -f Dockerfile -t livesync-cli ../../..", "check": "tsc -p tsconfig.json", - "test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/runtimeState.unit.spec.ts src/apps/cli/services/NodeKeyValueDBService.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/journalSkip.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts", + "test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/runtimeState.unit.spec.ts src/apps/cli/services/NodeKeyValueDBService.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/services/storageCaseSensitivity.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/journalSkip.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts", "test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh", "test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh", "test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh", diff --git a/src/apps/cli/services/NodeServiceHub.ts b/src/apps/cli/services/NodeServiceHub.ts index ad436209..b1446fc2 100644 --- a/src/apps/cli/services/NodeServiceHub.ts +++ b/src/apps/cli/services/NodeServiceHub.ts @@ -22,6 +22,7 @@ import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ import { HeadlessAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/headless/HeadlessAPIService"; import { NodeKeyValueDBService } from "./NodeKeyValueDBService"; import { createNodeLocalStorage } from "./NodeLocalStorage"; +import { isStorageCaseInsensitive } from "./storageCaseSensitivity"; import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { fs, path as nodePath } from "@vrtmrz/livesync-commonlib/node"; @@ -135,6 +136,10 @@ export class NodeServiceHub extends InjectableServ settingService: setting, APIService: API, }); + let storageInsensitive: boolean | undefined; + vault.isStorageInsensitive.setHandler( + () => (storageInsensitive ??= isStorageCaseInsensitive(context.runtimeVaultPath)) + ); const scopeDirectory = () => runtimeScopeDirectory(basePath, vault.getVaultName(), context.runtimeVaultPath); const test = new InjectableTestService(context); const databaseEvents = new InjectableDatabaseEventService(context); diff --git a/src/apps/cli/services/storageCaseSensitivity.ts b/src/apps/cli/services/storageCaseSensitivity.ts new file mode 100644 index 00000000..1d0557b6 --- /dev/null +++ b/src/apps/cli/services/storageCaseSensitivity.ts @@ -0,0 +1,14 @@ +import { fs, path } from "@vrtmrz/livesync-commonlib/node"; + +export function isStorageCaseInsensitive(vaultPath: string): boolean { + const probePath = fs.mkdtempSync(path.join(vaultPath, ".livesync-case-")); + try { + const original = fs.statSync(probePath); + const alternate = fs.statSync(path.join(vaultPath, path.basename(probePath).toUpperCase()), { + throwIfNoEntry: false, + }); + return alternate !== undefined && original.dev === alternate.dev && original.ino === alternate.ino; + } finally { + fs.rmdirSync(probePath); + } +} diff --git a/src/apps/cli/services/storageCaseSensitivity.unit.spec.ts b/src/apps/cli/services/storageCaseSensitivity.unit.spec.ts new file mode 100644 index 00000000..f96ebca3 --- /dev/null +++ b/src/apps/cli/services/storageCaseSensitivity.unit.spec.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fs, os, path } from "@vrtmrz/livesync-commonlib/node"; +import { isStorageCaseInsensitive } from "./storageCaseSensitivity"; +import { NodeServiceContext, NodeServiceHub } from "./NodeServiceHub"; + +vi.mock("@vrtmrz/livesync-commonlib/node", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fs: { ...actual.fs } }; +}); + +describe("CLI storage case sensitivity", () => { + let directory: string; + let vaultPath: string; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-storage-case-")); + vaultPath = path.join(directory, "vault"); + fs.mkdirSync(vaultPath); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it("matches the Vault filesystem and leaves existing files untouched", () => { + fs.writeFileSync(path.join(vaultPath, "Note.md"), "local content"); + const expected = fs.existsSync(path.join(vaultPath, "NOTE.MD")); + + expect(isStorageCaseInsensitive(vaultPath)).toBe(expected); + + expect(fs.readdirSync(vaultPath)).toEqual(["Note.md"]); + expect(fs.readFileSync(path.join(vaultPath, "Note.md"), "utf8")).toBe("local content"); + }); + + it("recognises an alternate spelling of the same filesystem entry", () => { + const entry = fs.statSync(vaultPath); + vi.spyOn(fs, "statSync").mockReturnValue(entry); + + expect(isStorageCaseInsensitive(vaultPath)).toBe(true); + expect(fs.readdirSync(vaultPath)).toEqual([]); + }); + + it("does not mistake a different entry for the probe", () => { + const original = fs.statSync(vaultPath); + const other = fs.statSync(directory); + vi.spyOn(fs, "statSync").mockReturnValueOnce(original).mockReturnValueOnce(other); + + expect(isStorageCaseInsensitive(vaultPath)).toBe(false); + expect(fs.readdirSync(vaultPath)).toEqual([]); + }); + + it("reports inspection failures and removes its probe", () => { + const failure = Object.assign(new Error("Cannot inspect the Vault"), { code: "EACCES" }); + vi.spyOn(fs, "statSync").mockImplementation(() => { + throw failure; + }); + + expect(() => isStorageCaseInsensitive(vaultPath)).toThrow(failure); + expect(fs.readdirSync(vaultPath)).toEqual([]); + }); + + it("lazily detects the selected Vault once per service hub", () => { + const createProbe = vi.spyOn(fs, "mkdtempSync"); + const context = new NodeServiceContext( + directory, + { + readStdin: async () => "", + prompt: async () => "", + writeStdout: () => undefined, + writeStderr: () => undefined, + }, + vaultPath + ); + const hub = new NodeServiceHub(directory, context); + expect(createProbe).not.toHaveBeenCalled(); + + const first = hub.vault.isStorageInsensitive(); + expect(hub.vault.isStorageInsensitive()).toBe(first); + expect(createProbe).toHaveBeenCalledExactlyOnceWith(path.join(vaultPath, ".livesync-case-")); + expect(fs.readdirSync(vaultPath)).toEqual([]); + }); +}); diff --git a/updates.md b/updates.md index 05dd3fad..83832cb8 100644 --- a/updates.md +++ b/updates.md @@ -48,6 +48,8 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi #### Fixed +- CLI `fetch` and `daemon` can write received files to the Vault when `handleFilenameCaseSensitive` is enabled. The CLI now detects whether the Vault filesystem distinguishes letter case. (#1319) + - Commonlib warnings now appear on stderr without `--debug`, so deliberately skipped Journals and other important notices remain visible during ordinary CLI use. - NFC file lookups and directory scans report permission and I/O failures instead of treating unreadable files as absent or returning a successful partial scan. A scan retains the comparison settings with which it began.