From dc6c4abdeb0aaa24e0a263d1c84f15e9ed1a8355 Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sat, 4 Jul 2026 14:16:25 +0800 Subject: [PATCH 01/22] refactor(file-types): introduce branded FilePathSchema Add a Zod schema that refines an absolute path, canonicalizes it via the shared canonicalizeAbsolutePath, and brands the output as FilePath. Additive for now; FilePath stays a template literal until the sweep in the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- .../file/__tests__/FilePathSchema.test.ts | 53 +++++++++++++++++++ src/shared/types/file/common.ts | 22 ++++++++ src/shared/types/file/index.ts | 1 + 3 files changed, 76 insertions(+) create mode 100644 src/shared/types/file/__tests__/FilePathSchema.test.ts diff --git a/src/shared/types/file/__tests__/FilePathSchema.test.ts b/src/shared/types/file/__tests__/FilePathSchema.test.ts new file mode 100644 index 00000000000..d97edf94f78 --- /dev/null +++ b/src/shared/types/file/__tests__/FilePathSchema.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { FilePathSchema } from '../common' + +describe('FilePathSchema', () => { + it('accepts a POSIX absolute path unchanged (already canonical)', () => { + expect(FilePathSchema.parse('/Users/me/doc.pdf')).toBe('/Users/me/doc.pdf') + }) + + it('accepts a Windows backslash absolute path unchanged', () => { + expect(FilePathSchema.parse('C:\\Users\\me\\doc.pdf')).toBe('C:\\Users\\me\\doc.pdf') + }) + + it('accepts a Windows forward-slash absolute path and canonicalizes to backslash', () => { + expect(FilePathSchema.parse('C:/Users/me/doc.pdf')).toBe('C:\\Users\\me\\doc.pdf') + }) + + it('NFC-normalizes decomposed (NFD) input', () => { + const nfd = '/Users/me/cafe\u0301.txt' // "café" as e + U+0301 combining acute (NFD) + const nfc = '/Users/me/caf\u00e9.txt' // "café" with precomposed U+00E9 (NFC) + expect(FilePathSchema.parse(nfd)).toBe(nfc) + }) + + it('strips a trailing separator', () => { + expect(FilePathSchema.parse('/foo/bar/')).toBe('/foo/bar') + }) + + it('resolves . and .. segments', () => { + expect(FilePathSchema.parse('/foo/./baz/../bar')).toBe('/foo/bar') + }) + + it('is idempotent', () => { + const once = FilePathSchema.parse('/foo/../bar/') + expect(FilePathSchema.parse(once)).toBe(once) + }) + + it('rejects a relative path', () => { + expect(FilePathSchema.safeParse('foo/bar').success).toBe(false) + expect(FilePathSchema.safeParse('./foo').success).toBe(false) + }) + + it('rejects a file:// URL', () => { + expect(FilePathSchema.safeParse('file:///Users/me/doc.pdf').success).toBe(false) + }) + + it('rejects an empty string', () => { + expect(FilePathSchema.safeParse('').success).toBe(false) + }) + + it('rejects a null byte', () => { + expect(FilePathSchema.safeParse('/foo/\0bar').success).toBe(false) + }) +}) diff --git a/src/shared/types/file/common.ts b/src/shared/types/file/common.ts index 9f95b4953c0..505c5e2bd61 100644 --- a/src/shared/types/file/common.ts +++ b/src/shared/types/file/common.ts @@ -2,6 +2,7 @@ * General file module types — used across ops, FileManager, and IPC. */ +import { canonicalizeAbsolutePath } from '@shared/utils/file/canonicalize' import * as z from 'zod' // ─── File Type Classification ─── @@ -28,6 +29,27 @@ export type FileType = z.infer // ─── Content Source Types ─── +/** + * Absolute filesystem path that has passed through `FilePathSchema`: + * NFC-normalized, segment-resolved, trailing-separator-stripped, no null bytes. + * + * The `z.brand` is a phantom brand — zero runtime cost, dropped on IPC + * serialization; receivers re-assert via `FilePathSchema.parse()` at the + * trusted boundary. Construction: + * - Production: `FilePathSchema.parse(raw)` / `.safeParse(raw)` + * - Tests / fixtures: `'…' as FilePath` for readability + * + * Accepts POSIX (`/…`) and Windows (`X:\…` or `X:/…`) absolute forms; the + * canonical output is always backslash on Windows. Rejects `file://` URLs. + */ +export const FilePathSchema = z + .string() + .min(1) + .refine((s) => !s.includes('\0'), 'must not contain null bytes') + .refine((s) => s.startsWith('/') || /^[A-Za-z]:[/\\]/.test(s), 'must be an absolute filesystem path') + .transform((v) => canonicalizeAbsolutePath(v)) + .brand<'FilePath'>() + /** * Local filesystem path (absolute Unix or Windows). * diff --git a/src/shared/types/file/index.ts b/src/shared/types/file/index.ts index 6c8ab154829..2f435fe7ea9 100644 --- a/src/shared/types/file/index.ts +++ b/src/shared/types/file/index.ts @@ -5,6 +5,7 @@ export { FILE_TYPE, type FileContent, type FilePath, + FilePathSchema, type FileType, FileTypeSchema, type FileUrlString, From 626cedc906ba8bcde5590d6e23c5ef7838427fc1 Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sat, 4 Jul 2026 16:04:51 +0800 Subject: [PATCH 02/22] refactor(file-types): unify FilePath as Zod-branded canonical path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip FilePath from a template literal to z.infer. Delete AbsolutePathSchema, CanonicalExternalPath, and CanonicalFilePath; route every schema field, service signature, and IPC boundary through FilePathSchema / FilePath. Drop the canonicalizeExternalPath wrapper and the manual path.resolve in resolvePhysicalPath — canonicalization now happens in the schema transform at the parse boundary. FileInfo/FilePathHandle path fields brand at parse, removing the FileInfo Omit hack. DB schema and IPC wire format unchanged (the brand is a phantom Zod brand). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- src/main/data/services/FileEntryService.ts | 44 +++--- .../__tests__/FileEntryService.test.ts | 58 ++------ .../FileProcessingService.integration.test.ts | 3 +- .../fileProcessing/persistence/artifacts.ts | 4 +- .../__tests__/toPersistable.test.ts | 3 +- .../__tests__/handler.test.ts | 2 +- .../paddleocr/__tests__/handler.test.ts | 6 +- .../image-to-text/__tests__/handler.test.ts | 4 +- .../image-to-text/__tests__/utils.test.ts | 4 +- .../__tests__/TesseractRuntimeService.test.ts | 5 +- .../__tests__/backgroundJobHandler.test.ts | 5 +- .../fileProcessing/tasks/jobExecution.ts | 2 +- .../__tests__/KnowledgeService.test.ts | 37 +++-- .../utils/__tests__/addConflicts.test.ts | 6 +- src/main/ipc/handlers/__tests__/file.test.ts | 11 +- src/main/services/file/FileManager.ts | 12 +- .../file/internal/__tests__/observe.test.ts | 8 +- .../internal/content/__tests__/read.test.ts | 10 +- .../internal/entry/__tests__/create.test.ts | 22 ++- .../entry/__tests__/lifecycle.test.ts | 4 +- .../services/file/internal/entry/create.ts | 59 ++++---- .../services/file/internal/entry/rename.ts | 19 ++- src/main/services/file/toFileInfo.ts | 2 +- .../file/tree/DirectoryTreeManager.ts | 8 +- .../file/utils/__tests__/pathResolver.test.ts | 59 +------- src/main/services/file/utils/pathResolver.ts | 129 ++---------------- .../file/watcher/__tests__/watcher.test.ts | 5 +- src/main/services/file/watcher/index.ts | 5 +- src/renderer/pages/files/FilesPage.tsx | 7 +- .../__tests__/PaintingComposerSwitch.test.tsx | 5 +- .../usePaintingComposerInputFiles.test.ts | 5 +- src/renderer/utils/knowledgeFileEntry.ts | 10 +- src/shared/data/types/file/fileEntry.ts | 113 +-------------- src/shared/data/types/file/index.ts | 2 +- src/shared/data/types/fileProcessing.ts | 6 +- src/shared/data/types/knowledge.ts | 8 +- src/shared/ipc/schemas/file.ts | 6 +- src/shared/types/file/common.ts | 9 +- src/shared/types/file/handle.ts | 7 +- src/shared/types/file/info.ts | 26 ++-- src/shared/types/file/ipc.ts | 48 ++----- .../utils/file/__tests__/handle.test.ts | 4 +- src/shared/utils/file/canonicalize.ts | 14 +- 43 files changed, 242 insertions(+), 564 deletions(-) diff --git a/src/main/data/services/FileEntryService.ts b/src/main/data/services/FileEntryService.ts index 89abf10cfc5..a5c1e96ed09 100644 --- a/src/main/data/services/FileEntryService.ts +++ b/src/main/data/services/FileEntryService.ts @@ -29,15 +29,10 @@ import type { DbOrTx } from '@data/db/types' import { loggerService } from '@logger' import { DataApiErrorFactory } from '@shared/data/api' import type { FileEntryListResponse, FileEntryStats } from '@shared/data/api/schemas/files' -import type { CanonicalExternalPath, FileEntry, FileEntryId, FileEntryOrigin } from '@shared/data/types/file' -import { - AbsolutePathSchema, - ExternalEntrySchema, - FileEntrySchema, - InternalEntrySchema, - SafeNameSchema -} from '@shared/data/types/file' +import type { FileEntry, FileEntryId, FileEntryOrigin } from '@shared/data/types/file' +import { ExternalEntrySchema, FileEntrySchema, InternalEntrySchema, SafeNameSchema } from '@shared/data/types/file' import { chatMessageSourceType, paintingSourceType } from '@shared/data/types/file/ref' +import type { FilePath } from '@shared/types/file' import { and, asc, count, eq, isNotNull, isNull, type SQL, sql, type SQLWrapper } from 'drizzle-orm' import { v7 as uuidv7 } from 'uuid' import * as z from 'zod' @@ -119,12 +114,12 @@ export interface FileEntryService { /** * Look up an external entry by canonical `externalPath`. Returns `null` when - * no row matches. The `CanonicalExternalPath` brand forces callers through - * `canonicalizeExternalPath()` at compile time — raw `string` values are + * no row matches. The `FilePath` brand forces callers through + * `FilePathSchema.parse()` at compile time — raw `string` values are * not assignable here, which prevents the "caller forgot to canonicalize" * class of bug that would silently miss all matches. */ - findByExternalPath(canonicalPath: CanonicalExternalPath): FileEntry | null + findByExternalPath(canonicalPath: FilePath): FileEntry | null /** * Return external entries whose `externalPath` matches `canonicalPath` @@ -152,7 +147,7 @@ export interface FileEntryService { * * Un-parseable rows are skipped with a warning (see `rowToFileEntrySafe`). */ - findCaseInsensitivePeers(canonicalPath: CanonicalExternalPath): FileEntry[] + findCaseInsensitivePeers(canonicalPath: FilePath): FileEntry[] /** * Flat listing. Trashed filter defaults to "active only" when `inTrash` is omitted. @@ -222,10 +217,10 @@ export interface FileEntryService { * doing it as a single statement keeps the (path, name) pair consistent under * partial-failure scenarios (transient lock, schema constraint). */ - setExternalPathAndName(id: FileEntryId, externalPath: CanonicalExternalPath, name: string): FileEntry + setExternalPathAndName(id: FileEntryId, externalPath: FilePath, name: string): FileEntry /** Tx-scoped variant of `setExternalPathAndName` for composing write flows. */ - setExternalPathAndNameTx(tx: DbOrTx, id: FileEntryId, externalPath: CanonicalExternalPath, name: string): FileEntry + setExternalPathAndNameTx(tx: DbOrTx, id: FileEntryId, externalPath: FilePath, name: string): FileEntry /** Remove the row (CASCADE drops dependent persistent file refs). No-op if already gone. */ delete(id: FileEntryId): void @@ -392,7 +387,7 @@ class FileEntryServiceImpl implements FileEntryService { return entry } - findByExternalPath(canonicalPath: CanonicalExternalPath): FileEntry | null { + findByExternalPath(canonicalPath: FilePath): FileEntry | null { const rows = this.getDb() .select() .from(fileEntryTable) @@ -402,7 +397,7 @@ class FileEntryServiceImpl implements FileEntryService { return rows.length === 0 ? null : rowToFileEntry(rows[0]) } - findCaseInsensitivePeers(canonicalPath: CanonicalExternalPath): FileEntry[] { + findCaseInsensitivePeers(canonicalPath: FilePath): FileEntry[] { const rows = this.getDb() .select() .from(fileEntryTable) @@ -609,19 +604,18 @@ class FileEntryServiceImpl implements FileEntryService { * the only sanctioned mutation site for `externalPath`. Used by the rename * flow so the (path, name) pair stays consistent under failure. */ - setExternalPathAndName(id: FileEntryId, externalPath: CanonicalExternalPath, name: string): FileEntry { + setExternalPathAndName(id: FileEntryId, externalPath: FilePath, name: string): FileEntry { return this.setExternalPathAndNameTx(this.getDb(), id, externalPath, name) } - setExternalPathAndNameTx(tx: DbOrTx, id: FileEntryId, externalPath: CanonicalExternalPath, name: string): FileEntry { - // Same pre-SQL validation rationale as `update` above; an unsafe value - // for either column would corrupt the row past `rowToFileEntry` parse. - // The `CanonicalExternalPath` brand is TS-only — defense-in-depth at the - // runtime layer rejects path strings the brand failed to flag (e.g. a - // caller that `as`-cast a raw user string instead of going through - // `canonicalizeExternalPath`). + setExternalPathAndNameTx(tx: DbOrTx, id: FileEntryId, externalPath: FilePath, name: string): FileEntry { + // Same pre-SQL validation rationale as `update` above; an unsafe `name` + // would corrupt the row past `rowToFileEntry` parse. `externalPath` needs + // no re-parse here: the `FilePath` brand can only be produced by + // `FilePathSchema.parse()`, so callers already proved canonicalization at + // construction time — re-running it would just re-verify a fact the type + // system already guarantees. SafeNameSchema.parse(name) - AbsolutePathSchema.parse(externalPath) const rows = tx .update(fileEntryTable) .set({ externalPath, name, updatedAt: Date.now() }) diff --git a/src/main/data/services/__tests__/FileEntryService.test.ts b/src/main/data/services/__tests__/FileEntryService.test.ts index 9d746595703..1da125d24d0 100644 --- a/src/main/data/services/__tests__/FileEntryService.test.ts +++ b/src/main/data/services/__tests__/FileEntryService.test.ts @@ -4,7 +4,8 @@ import { messageTable } from '@data/db/schemas/message' import { paintingTable } from '@data/db/schemas/painting' import { topicTable } from '@data/db/schemas/topic' import { DataApiError, ErrorCode } from '@shared/data/api' -import type { CanonicalExternalPath, FileEntryId } from '@shared/data/types/file' +import type { FileEntryId } from '@shared/data/types/file' +import type { FilePath } from '@shared/types/file' import { setupTestDatabase } from '@test-helpers/db' import { MockMainDbServiceExport, MockMainDbServiceUtils } from '@test-mocks/main/DbService' import { mockMainLoggerService } from '@test-mocks/MainLoggerService' @@ -117,13 +118,13 @@ describe('FileEntryService', () => { updatedAt: now }) - const entry = fileEntryService.findByExternalPath('/Users/me/doc.pdf' as CanonicalExternalPath) + const entry = fileEntryService.findByExternalPath('/Users/me/doc.pdf' as FilePath) expect(entry?.id).toBe(id) expect(entry?.origin).toBe('external') }) it('returns null when no row matches', async () => { - const result = fileEntryService.findByExternalPath('/Users/me/nonexistent.pdf' as CanonicalExternalPath) + const result = fileEntryService.findByExternalPath('/Users/me/nonexistent.pdf' as FilePath) expect(result).toBeNull() }) @@ -142,7 +143,7 @@ describe('FileEntryService', () => { updatedAt: now }) - const result = fileEntryService.findByExternalPath('/Users/me/A.TXT' as CanonicalExternalPath) + const result = fileEntryService.findByExternalPath('/Users/me/A.TXT' as FilePath) expect(result).toBeNull() }) }) @@ -167,13 +168,13 @@ describe('FileEntryService', () => { updatedAt: now }) - const peers = fileEntryService.findCaseInsensitivePeers('/Users/me/a.txt' as CanonicalExternalPath) + const peers = fileEntryService.findCaseInsensitivePeers('/Users/me/a.txt' as FilePath) expect(peers).toHaveLength(1) expect(peers[0]?.id).toBe('019606a0-0000-7000-8000-000000000020') }) it('returns empty array when no rows match', async () => { - const peers = fileEntryService.findCaseInsensitivePeers('/zzz/none.txt' as CanonicalExternalPath) + const peers = fileEntryService.findCaseInsensitivePeers('/zzz/none.txt' as FilePath) expect(peers).toEqual([]) }) @@ -1176,11 +1177,7 @@ describe('FileEntryService', () => { const original = fileEntryService.getById(id) await new Promise((r) => setTimeout(r, 5)) - const updated = fileEntryService.setExternalPathAndName( - id, - '/Users/me/new-doc.pdf' as CanonicalExternalPath, - 'new-doc' - ) + const updated = fileEntryService.setExternalPathAndName(id, '/Users/me/new-doc.pdf' as FilePath, 'new-doc') expect(updated.id).toBe(id) if (updated.origin !== 'external') throw new Error('expected external entry') @@ -1199,7 +1196,7 @@ describe('FileEntryService', () => { const missing = '019606a0-0000-7000-8000-000000000dff' as FileEntryId let err: unknown try { - fileEntryService.setExternalPathAndName(missing, '/Users/me/ghost.pdf' as CanonicalExternalPath, 'ghost') + fileEntryService.setExternalPathAndName(missing, '/Users/me/ghost.pdf' as FilePath, 'ghost') } catch (e) { err = e } @@ -1222,28 +1219,7 @@ describe('FileEntryService', () => { }) expect(() => - fileEntryService.setExternalPathAndName(entry.id, '/Users/me/legit.txt' as CanonicalExternalPath, '../evil') - ).toThrow() - - const [raw] = await dbh.db.select().from(fileEntryTable).where(eq(fileEntryTable.id, entry.id)) - expect(raw?.name).toBe('safe') - expect(raw?.externalPath).toBe('/Users/me/safe.txt') - }) - - it('rejects unsafe externalPath BEFORE the SQL UPDATE commits', async () => { - // The `CanonicalExternalPath` brand is TS-only and offers no runtime - // guarantee. The service-side `AbsolutePathSchema.parse(externalPath)` - // catches null bytes / non-absolute paths regardless of whether the - // caller went through `canonicalizeExternalPath` or `as`-cast. - const entry = fileEntryService.create({ - origin: 'external', - name: 'safe', - ext: 'txt', - externalPath: '/Users/me/safe.txt' - }) - - expect(() => - fileEntryService.setExternalPathAndName(entry.id, '/Users/me/null\0byte.txt' as CanonicalExternalPath, 'fine') + fileEntryService.setExternalPathAndName(entry.id, '/Users/me/legit.txt' as FilePath, '../evil') ).toThrow() const [raw] = await dbh.db.select().from(fileEntryTable).where(eq(fileEntryTable.id, entry.id)) @@ -1276,7 +1252,7 @@ describe('FileEntryService', () => { // unexpected and bubble it up. let err: Error | null = null try { - fileEntryService.setExternalPathAndName(b.id, '/Users/me/a.txt' as CanonicalExternalPath, 'a') + fileEntryService.setExternalPathAndName(b.id, '/Users/me/a.txt' as FilePath, 'a') } catch (e) { err = e as Error } @@ -1305,11 +1281,7 @@ describe('FileEntryService', () => { ext: 'txt', externalPath: '/Users/me/ext-tx.txt' }) - fileEntryService.setExternalPathAndName( - external.id, - '/Users/me/ext-tx-renamed.txt' as CanonicalExternalPath, - 'ext-tx-renamed' - ) + fileEntryService.setExternalPathAndName(external.id, '/Users/me/ext-tx-renamed.txt' as FilePath, 'ext-tx-renamed') // create/update/delete/setExternalPathAndName are each a single autocommit statement under // better-sqlite3, so they write via getDb() directly and never wrap withWriteTx. If any grows @@ -1477,7 +1449,7 @@ describe('FileEntryService', () => { origin: 'external', name: 'e', ext: 'txt', - externalPath: '/abs/orphan.txt' as CanonicalExternalPath + externalPath: '/abs/orphan.txt' as FilePath }) const externalsOnly = fileEntryService.findUnreferenced({ origin: 'external' }) @@ -1606,7 +1578,7 @@ describe('FileEntryService', () => { mockMainLoggerService.warn.mockClear() // Corrupt match → excluded with one warning, not a throw. - const badPeers = fileEntryService.findCaseInsensitivePeers('/users/me/bad-peer.txt' as CanonicalExternalPath) + const badPeers = fileEntryService.findCaseInsensitivePeers('/users/me/bad-peer.txt' as FilePath) expect(badPeers).toEqual([]) expect(mockMainLoggerService.warn).toHaveBeenCalledTimes(1) expect(mockMainLoggerService.warn).toHaveBeenCalledWith( @@ -1615,7 +1587,7 @@ describe('FileEntryService', () => { ) // Good rows still surface through the same method. - const goodPeers = fileEntryService.findCaseInsensitivePeers('/users/me/good-peer.txt' as CanonicalExternalPath) + const goodPeers = fileEntryService.findCaseInsensitivePeers('/users/me/good-peer.txt' as FilePath) expect(goodPeers.map((e) => e.id)).toEqual([goodExternalId]) }) }) diff --git a/src/main/features/fileProcessing/__tests__/FileProcessingService.integration.test.ts b/src/main/features/fileProcessing/__tests__/FileProcessingService.integration.test.ts index f91e785cac8..efa0b608715 100644 --- a/src/main/features/fileProcessing/__tests__/FileProcessingService.integration.test.ts +++ b/src/main/features/fileProcessing/__tests__/FileProcessingService.integration.test.ts @@ -10,6 +10,7 @@ import type * as LifecycleModule from '@main/core/lifecycle' import { getDependencies, getPhase } from '@main/core/lifecycle/decorators' import { Phase } from '@main/core/lifecycle/types' +import type { FilePath } from '@shared/types/file' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -132,7 +133,7 @@ const FAKE_PDF_INFO = { modifiedAt: 1 } -const MARKDOWN_OUTPUT = { kind: 'path' as const, path: '/tmp/out.md' } +const MARKDOWN_OUTPUT = { kind: 'path' as const, path: '/tmp/out.md' as FilePath } const entryPayload = ( feature: 'image_to_text' | 'document_to_markdown', diff --git a/src/main/features/fileProcessing/persistence/artifacts.ts b/src/main/features/fileProcessing/persistence/artifacts.ts index d899dfe96df..f0d111ea784 100644 --- a/src/main/features/fileProcessing/persistence/artifacts.ts +++ b/src/main/features/fileProcessing/persistence/artifacts.ts @@ -26,7 +26,7 @@ export function getFileProcessingMarkdownArtifactPath(snapshot: JobSnapshot): Fi if (!isMarkdownFileArtifact(output.artifact)) { throw new Error(`File processing job ${snapshot.id} completed without a markdown path artifact`) } - return output.artifact.path as FilePath + return output.artifact.path } export function isMarkdownFileArtifact( @@ -73,7 +73,7 @@ async function createFileProcessingArtifact( path: await markdownResultStore.persistResultToPath({ jobId, result: output, - path: input.output.path as FilePath, + path: input.output.path, signal }) } diff --git a/src/main/features/fileProcessing/processors/__tests__/toPersistable.test.ts b/src/main/features/fileProcessing/processors/__tests__/toPersistable.test.ts index 77dfcbf3a66..79a24074307 100644 --- a/src/main/features/fileProcessing/processors/__tests__/toPersistable.test.ts +++ b/src/main/features/fileProcessing/processors/__tests__/toPersistable.test.ts @@ -16,8 +16,7 @@ import { mineruDocumentToMarkdownHandler } from '../mineru/document-to-markdown/ import { paddleDocumentToMarkdownHandler } from '../paddleocr/document-to-markdown/handler' import type { PreparedRemoteJob } from '../types' -const createFileInfo = (input: Parameters[0]): FileInfo => - FileInfoSchema.parse(input) as FileInfo +const createFileInfo = (input: Parameters[0]): FileInfo => FileInfoSchema.parse(input) const FAKE_PDF = createFileInfo({ path: '/tmp/paper.pdf', diff --git a/src/main/features/fileProcessing/processors/mistral/document-to-markdown/__tests__/handler.test.ts b/src/main/features/fileProcessing/processors/mistral/document-to-markdown/__tests__/handler.test.ts index ac99a4b7595..f80c596bbf4 100644 --- a/src/main/features/fileProcessing/processors/mistral/document-to-markdown/__tests__/handler.test.ts +++ b/src/main/features/fileProcessing/processors/mistral/document-to-markdown/__tests__/handler.test.ts @@ -194,5 +194,5 @@ function createFile(): FileInfo { type: 'document', createdAt: 1, modifiedAt: 1 - }) as FileInfo + }) } diff --git a/src/main/features/fileProcessing/processors/paddleocr/__tests__/handler.test.ts b/src/main/features/fileProcessing/processors/paddleocr/__tests__/handler.test.ts index e1791722bb6..ed8c5e0c634 100644 --- a/src/main/features/fileProcessing/processors/paddleocr/__tests__/handler.test.ts +++ b/src/main/features/fileProcessing/processors/paddleocr/__tests__/handler.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises' import type { FileProcessorMerged } from '@shared/data/presets/fileProcessing' -import { type FileInfo, FileInfoSchema } from '@shared/types/file' +import { FileInfoSchema } from '@shared/types/file' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -53,7 +53,7 @@ const documentFile = FileInfoSchema.parse({ type: 'document', createdAt: 1, modifiedAt: 1 -}) as FileInfo +}) const imageFile = FileInfoSchema.parse({ path: '/tmp/input.png', @@ -64,7 +64,7 @@ const imageFile = FileInfoSchema.parse({ type: 'image', createdAt: 1, modifiedAt: 1 -}) as FileInfo +}) function createConfig(feature: 'image_to_text' | 'document_to_markdown', modelId: string): FileProcessorMerged { return { diff --git a/src/main/features/fileProcessing/processors/system/image-to-text/__tests__/handler.test.ts b/src/main/features/fileProcessing/processors/system/image-to-text/__tests__/handler.test.ts index 95e6b657e31..a69065395a1 100644 --- a/src/main/features/fileProcessing/processors/system/image-to-text/__tests__/handler.test.ts +++ b/src/main/features/fileProcessing/processors/system/image-to-text/__tests__/handler.test.ts @@ -1,5 +1,5 @@ import { FILE_TYPE } from '@shared/data/types/file' -import { type FileInfo, FileInfoSchema } from '@shared/types/file' +import { FileInfoSchema } from '@shared/types/file' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockMainLoggerService } from '../../../../../../../../tests/__mocks__/MainLoggerService' @@ -27,7 +27,7 @@ const imageFile = FileInfoSchema.parse({ type: FILE_TYPE.IMAGE, createdAt: 1, modifiedAt: 1 -}) as FileInfo +}) describe('systemImageToTextHandler', () => { beforeEach(() => { diff --git a/src/main/features/fileProcessing/processors/tesseract/image-to-text/__tests__/utils.test.ts b/src/main/features/fileProcessing/processors/tesseract/image-to-text/__tests__/utils.test.ts index d6b9d9b0103..01485252b83 100644 --- a/src/main/features/fileProcessing/processors/tesseract/image-to-text/__tests__/utils.test.ts +++ b/src/main/features/fileProcessing/processors/tesseract/image-to-text/__tests__/utils.test.ts @@ -1,5 +1,5 @@ import { FILE_TYPE } from '@shared/data/types/file' -import { type FileInfo, FileInfoSchema } from '@shared/types/file' +import { FileInfoSchema } from '@shared/types/file' import { describe, expect, it, vi } from 'vitest' import { mockMainLoggerService } from '../../../../../../../../tests/__mocks__/MainLoggerService' @@ -14,7 +14,7 @@ const imageFile = FileInfoSchema.parse({ type: FILE_TYPE.IMAGE, createdAt: 1, modifiedAt: 1 -}) as FileInfo +}) describe('Tesseract prepareContext', () => { it('parses migrated langs arrays from processor options', () => { diff --git a/src/main/features/fileProcessing/processors/tesseract/runtime/__tests__/TesseractRuntimeService.test.ts b/src/main/features/fileProcessing/processors/tesseract/runtime/__tests__/TesseractRuntimeService.test.ts index 51b9b8a78d1..906e5c62590 100644 --- a/src/main/features/fileProcessing/processors/tesseract/runtime/__tests__/TesseractRuntimeService.test.ts +++ b/src/main/features/fileProcessing/processors/tesseract/runtime/__tests__/TesseractRuntimeService.test.ts @@ -6,6 +6,7 @@ import { getPhase } from '@main/core/lifecycle/decorators' import { Phase } from '@main/core/lifecycle/types' import { type FileInfo, FileInfoSchema } from '@shared/types/file' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as z from 'zod' import type { PreparedTesseractContext } from '../../types' @@ -48,7 +49,7 @@ async function flushPromises(): Promise { await Promise.resolve() } -function createFileInfo(overrides: Partial = {}): FileInfo { +function createFileInfo(overrides: Partial> = {}): FileInfo { return FileInfoSchema.parse({ path: '/tmp/scan.png', name: 'scan', @@ -59,7 +60,7 @@ function createFileInfo(overrides: Partial = {}): FileInfo { createdAt: 1, modifiedAt: 1, ...overrides - }) as FileInfo + }) } const cleanupCases = [ diff --git a/src/main/features/fileProcessing/tasks/__tests__/backgroundJobHandler.test.ts b/src/main/features/fileProcessing/tasks/__tests__/backgroundJobHandler.test.ts index 303220442e0..65dc990201a 100644 --- a/src/main/features/fileProcessing/tasks/__tests__/backgroundJobHandler.test.ts +++ b/src/main/features/fileProcessing/tasks/__tests__/backgroundJobHandler.test.ts @@ -7,6 +7,7 @@ * post-success output). */ import type { JobContext } from '@main/core/job/types' +import type { FilePath } from '@shared/types/file' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { FileProcessingJobPayload } from '../shared' @@ -174,7 +175,7 @@ describe('backgroundJobHandler.execute', () => { input: { feature: 'image_to_text', file: { kind: 'entry', entryId: FILE_ENTRY_ID }, - output: { kind: 'path', path: '/tmp/out.md' }, + output: { kind: 'path', path: '/tmp/out.md' as FilePath }, processorId: 'tesseract' } }) @@ -211,7 +212,7 @@ describe('backgroundJobHandler.execute', () => { input: { feature: 'image_to_text', file: { kind: 'entry', entryId: FILE_ENTRY_ID }, - output: { kind: 'path', path: '/tmp/out.md' }, + output: { kind: 'path', path: '/tmp/out.md' as FilePath }, processorId: 'tesseract' } }) diff --git a/src/main/features/fileProcessing/tasks/jobExecution.ts b/src/main/features/fileProcessing/tasks/jobExecution.ts index bf3bb510929..8dc27a6320e 100644 --- a/src/main/features/fileProcessing/tasks/jobExecution.ts +++ b/src/main/features/fileProcessing/tasks/jobExecution.ts @@ -177,5 +177,5 @@ async function resolveFileProcessingPathInfo(filePath: FilePath): Promise { knowledgeItemUpdateStatusMock.mockReturnValueOnce(processingFile) knowledgeItemGetByIdMock.mockReturnValueOnce(processingFile) - await service.addItems('kb-1', [{ type: 'file', data: { source: '/docs/source.pdf', path: '/docs/source.pdf' } }]) + await service.addItems('kb-1', [ + { type: 'file', data: { source: '/docs/source.pdf', path: '/docs/source.pdf' as FilePath } } + ]) expect(fileProcessingStartJobMock).toHaveBeenCalledWith( { @@ -1059,8 +1062,8 @@ describe('KnowledgeService', () => { .mockReturnValueOnce(createFileItem('file-2', 'kb-1', '/Users/me/b/notes.md', 'processing')) await service.addItems('kb-1', [ - { type: 'file', data: { source: '/Users/me/a/notes.md', path: '/Users/me/a/notes.md' } }, - { type: 'file', data: { source: '/Users/me/b/notes.md', path: '/Users/me/b/notes.md' } } + { type: 'file', data: { source: '/Users/me/a/notes.md', path: '/Users/me/a/notes.md' as FilePath } }, + { type: 'file', data: { source: '/Users/me/b/notes.md', path: '/Users/me/b/notes.md' as FilePath } } ]) // Both imports land; the second's relativePath is deduped (`_N`) rather than refused. @@ -1083,8 +1086,8 @@ describe('KnowledgeService', () => { .mockReturnValueOnce(createFileItem('file-2', 'kb-1', '/Users/me/b/brief.docx', 'processing')) await service.addItems('kb-1', [ - { type: 'file', data: { source: '/Users/me/a/brief.pdf', path: '/Users/me/a/brief.pdf' } }, - { type: 'file', data: { source: '/Users/me/b/brief.docx', path: '/Users/me/b/brief.docx' } } + { type: 'file', data: { source: '/Users/me/a/brief.pdf', path: '/Users/me/a/brief.pdf' as FilePath } }, + { type: 'file', data: { source: '/Users/me/b/brief.docx', path: '/Users/me/b/brief.docx' as FilePath } } ]) // brief.pdf reserves brief.pdf + its brief.md output; brief.docx would also emit @@ -1112,7 +1115,7 @@ describe('KnowledgeService', () => { data: { source: 'https://example.com/new', url: 'https://example.com/new', - snapshotPath: '/captured/example-page.md' + snapshotPath: '/captured/example-page.md' as FilePath } } ]) @@ -1150,7 +1153,7 @@ describe('KnowledgeService', () => { data: { source: 'https://example.com/p', url: 'https://example.com/p', - snapshotPath: '/captured/example-page.md' + snapshotPath: '/captured/example-page.md' as FilePath } } ]) @@ -1186,7 +1189,7 @@ describe('KnowledgeService', () => { ) await service.addItems('kb-1', [ - { type: 'file', data: { source: '/Users/me/Meeting notes.md', path: '/Users/me/Meeting notes.md' } } + { type: 'file', data: { source: '/Users/me/Meeting notes.md', path: '/Users/me/Meeting notes.md' as FilePath } } ]) // The new file's name collides with the existing note's reserved snapshot path, so it is @@ -1245,7 +1248,7 @@ describe('KnowledgeService', () => { knowledgeItemGetItemsByBaseIdMock.mockReturnValue([createFileItem('file-existing', 'kb-1', '/old/notes.md')]) await service.addItems('kb-1', [ - { type: 'file', data: { source: '/Users/me/c/notes.md', path: '/Users/me/c/notes.md' } } + { type: 'file', data: { source: '/Users/me/c/notes.md', path: '/Users/me/c/notes.md' as FilePath } } ]) expect(copyFileIntoKnowledgeBaseAtMock).toHaveBeenCalledWith('kb-1', '/Users/me/c/notes.md', 'notes_1.md') @@ -1260,7 +1263,7 @@ describe('KnowledgeService', () => { knowledgeItemGetItemsByBaseIdMock.mockReturnValue([createFileItem('file-existing', 'kb-1', '/old/brief.pdf')]) await service.addItems('kb-1', [ - { type: 'file', data: { source: '/Users/me/c/brief.md', path: '/Users/me/c/brief.md' } } + { type: 'file', data: { source: '/Users/me/c/brief.md', path: '/Users/me/c/brief.md' as FilePath } } ]) expect(copyFileIntoKnowledgeBaseAtMock).toHaveBeenCalledWith('kb-1', '/Users/me/c/brief.md', 'brief_1.md') @@ -1271,7 +1274,9 @@ describe('KnowledgeService', () => { knowledgeBaseGetByIdMock.mockReturnValue(createBase({ fileProcessorId: null })) await expect( - service.addItems('kb-1', [{ type: 'file', data: { source: '/Users/me/app.exe', path: '/Users/me/app.exe' } }]) + service.addItems('kb-1', [ + { type: 'file', data: { source: '/Users/me/app.exe', path: '/Users/me/app.exe' as FilePath } } + ]) ).rejects.toThrow('Unsupported knowledge file type: /Users/me/app.exe') expect(knowledgeItemCreateMock).not.toHaveBeenCalled() @@ -1447,7 +1452,9 @@ describe('KnowledgeService', () => { knowledgeItemUpdateStatusMock.mockReturnValueOnce(processingFile) knowledgeItemGetByIdMock.mockReturnValueOnce(processingFile) - await service.addItems('kb-1', [{ type: 'file', data: { source: '/docs/source.md', path: '/docs/source.md' } }]) + await service.addItems('kb-1', [ + { type: 'file', data: { source: '/docs/source.md', path: '/docs/source.md' as FilePath } } + ]) expect(fileProcessingStartJobMock).not.toHaveBeenCalled() expect(enqueueMock).toHaveBeenCalledWith( @@ -1470,7 +1477,9 @@ describe('KnowledgeService', () => { knowledgeItemUpdateStatusMock.mockReturnValueOnce(processingFile) knowledgeItemGetByIdMock.mockReturnValueOnce(processingFile) - await service.addItems('kb-1', [{ type: 'file', data: { source: '/docs/source.pdf', path: '/docs/source.pdf' } }]) + await service.addItems('kb-1', [ + { type: 'file', data: { source: '/docs/source.pdf', path: '/docs/source.pdf' as FilePath } } + ]) expect(fileProcessingStartJobMock).not.toHaveBeenCalled() expect(enqueueMock).toHaveBeenCalledWith( @@ -1534,7 +1543,7 @@ describe('KnowledgeService', () => { }) await expect( - service.addItems('kb-1', [{ type: 'file', data: { source: '/docs/x.pdf', path: '/docs/x.pdf' } }]) + service.addItems('kb-1', [{ type: 'file', data: { source: '/docs/x.pdf', path: '/docs/x.pdf' as FilePath } }]) ).rejects.toThrow('create failed') // Copied-file cleanup is delegated to the best-effort variant, which swallows its diff --git a/src/main/features/knowledge/utils/__tests__/addConflicts.test.ts b/src/main/features/knowledge/utils/__tests__/addConflicts.test.ts index 5b18a8fe0a3..70c405ad9dc 100644 --- a/src/main/features/knowledge/utils/__tests__/addConflicts.test.ts +++ b/src/main/features/knowledge/utils/__tests__/addConflicts.test.ts @@ -1,4 +1,5 @@ import type { KnowledgeAddItemInput, KnowledgeItem } from '@shared/data/types/knowledge' +import type { FilePath } from '@shared/types/file' import { describe, expect, it } from 'vitest' import { resolveKnowledgeAddConflicts } from '../addConflicts' @@ -15,7 +16,10 @@ const existingItem = (id: string, partial: Pick) ...partial }) as KnowledgeItem -const fileInput = (source: string): KnowledgeAddItemInput => ({ type: 'file', data: { source, path: source } }) +const fileInput = (source: string): KnowledgeAddItemInput => ({ + type: 'file', + data: { source, path: source as FilePath } +}) const urlInput = (url: string): KnowledgeAddItemInput => ({ type: 'url', data: { source: url, url } }) const noteInput = (content: string): KnowledgeAddItemInput => ({ type: 'note', data: { source: 'note', content } }) diff --git a/src/main/ipc/handlers/__tests__/file.test.ts b/src/main/ipc/handlers/__tests__/file.test.ts index ed5c125898b..a1ea5cdde50 100644 --- a/src/main/ipc/handlers/__tests__/file.test.ts +++ b/src/main/ipc/handlers/__tests__/file.test.ts @@ -1,3 +1,4 @@ +import type { FilePath } from '@shared/types/file' import { beforeEach, describe, expect, it, vi } from 'vitest' const { appGetMock, getMetadataByPathMock, safeOpenMock, showPathInFolderMock } = vi.hoisted(() => ({ @@ -56,7 +57,7 @@ describe('fileHandlers', () => { it('batch_get_metadata dispatches FileHandle items inside the IPC adapter', async () => { const items = [ { key: ids[0], handle: { kind: 'entry' as const, entryId: ids[0] } }, - { key: '/tmp/a.txt', handle: { kind: 'path' as const, path: '/tmp/a.txt' } }, + { key: '/tmp/a.txt', handle: { kind: 'path' as const, path: '/tmp/a.txt' as FilePath } }, { key: ids[1], handle: { kind: 'entry' as const, entryId: ids[1] } } ] fileManager.getMetadata.mockResolvedValueOnce(metadata).mockRejectedValueOnce(new Error('ENOENT')) @@ -121,8 +122,8 @@ describe('fileHandlers', () => { }) it('dispatches path system commands without FileManager entry lookup', async () => { - await fileHandlers['file.open']({ kind: 'path', path: '/tmp/report.md' }, ctx) - await fileHandlers['file.show_in_folder']({ kind: 'path', path: '/tmp/report.md' }, ctx) + await fileHandlers['file.open']({ kind: 'path', path: '/tmp/report.md' as FilePath }, ctx) + await fileHandlers['file.show_in_folder']({ kind: 'path', path: '/tmp/report.md' as FilePath }, ctx) expect(safeOpenMock).toHaveBeenCalledWith('/tmp/report.md') expect(showPathInFolderMock).toHaveBeenCalledWith('/tmp/report.md') @@ -133,8 +134,8 @@ describe('fileHandlers', () => { it('delegates internal-entry batch create items to FileManager', async () => { const result = { succeeded: [{ id: ids[0], sourceRef: '/tmp/a.txt' }], failed: [] } const items = [ - { source: 'path' as const, path: '/tmp/a.txt' }, - { source: 'path' as const, path: '/tmp/b.txt' } + { source: 'path' as const, path: '/tmp/a.txt' as FilePath }, + { source: 'path' as const, path: '/tmp/b.txt' as FilePath } ] fileManager.batchCreateInternalEntries.mockResolvedValue(result) diff --git a/src/main/services/file/FileManager.ts b/src/main/services/file/FileManager.ts index 9c78b8ba41b..9d2b2a34ea4 100644 --- a/src/main/services/file/FileManager.ts +++ b/src/main/services/file/FileManager.ts @@ -134,7 +134,7 @@ import { loggerService } from '@logger' import { BaseService, Injectable, Phase, ServicePhase } from '@main/core/lifecycle' import { remove as fsRemove, stat as fsStat } from '@main/utils/file/fs' import type { DanglingState, FileEntry, FileEntryId } from '@shared/data/types/file' -import { AbsolutePathSchema, FileEntryIdSchema } from '@shared/data/types/file' +import { FileEntryIdSchema, FilePathSchema } from '@shared/data/types/file' import { SafeNameSchema } from '@shared/data/types/file/essential' import { IpcChannel } from '@shared/IpcChannel' import type { @@ -189,7 +189,7 @@ import { showInFolder as internalShellShowInFolder } from './internal/system/she import { withTempCopy as internalWithTempCopy } from './internal/system/tempCopy' import { safeOpen } from './system' import { getMetadataByPath } from './utils/metadata' -import { canonicalizeExternalPath, resolvePhysicalPath } from './utils/pathResolver' +import { resolvePhysicalPath } from './utils/pathResolver' import { createVersionCacheImpl, type VersionCache } from './versionCache' const fileManagerLogger = loggerService.withContext('FileManager') @@ -232,7 +232,7 @@ export type EnsureExternalEntryParams = EnsureExternalEntryIpcParams const SafeExtNullableSchema = SafeExtSchema.nullable() export const CreateInternalEntryIpcSchema = z.discriminatedUnion('source', [ - z.strictObject({ source: z.literal('path'), path: AbsolutePathSchema }), + z.strictObject({ source: z.literal('path'), path: FilePathSchema }), z.strictObject({ source: z.literal('url'), url: z.url() }), z.strictObject({ source: z.literal('base64'), data: z.string().min(1), name: SafeNameSchema.optional() }), z.strictObject({ @@ -243,7 +243,7 @@ export const CreateInternalEntryIpcSchema = z.discriminatedUnion('source', [ }) ]) -export const EnsureExternalEntryIpcSchema = z.strictObject({ externalPath: AbsolutePathSchema }) +export const EnsureExternalEntryIpcSchema = z.strictObject({ externalPath: FilePathSchema }) export const GetPhysicalPathIpcSchema = z.strictObject({ id: FileEntryIdSchema }) @@ -824,7 +824,7 @@ export class FileManager extends BaseService implements IFileManager { } async findByExternalPath(rawPath: string): Promise { - return this.deps.fileEntryService.findByExternalPath(canonicalizeExternalPath(rawPath)) + return this.deps.fileEntryService.findByExternalPath(FilePathSchema.parse(rawPath)) } async ensureExternalEntry(params: EnsureExternalEntryParams): Promise { @@ -923,7 +923,7 @@ export class FileManager extends BaseService implements IFileManager { for (const params of items) { const sourceRef = params.externalPath try { - const canonical = canonicalizeExternalPath(params.externalPath) + const canonical = FilePathSchema.parse(params.externalPath) const cached = seen.get(canonical) const entry = cached ?? (await this.ensureExternalEntry(params)) if (!cached) seen.set(canonical, entry) diff --git a/src/main/services/file/internal/__tests__/observe.test.ts b/src/main/services/file/internal/__tests__/observe.test.ts index b64490a6049..db406272674 100644 --- a/src/main/services/file/internal/__tests__/observe.test.ts +++ b/src/main/services/file/internal/__tests__/observe.test.ts @@ -17,10 +17,10 @@ import type { FileManagerDeps } from '../deps' import { observeExternalAccess } from '../observe' // `as unknown as FileEntry` because `externalPath` is now branded as -// `CanonicalFilePath` (FilePath & CanonicalExternalPath) — a string literal -// can't satisfy the brand directly. The actual canonicalization invariant -// is irrelevant for these tests (we never feed the entry back into the -// schema); they only need the discriminator + a stable physical path. +// `FilePath` (via `FilePathSchema`) — a string literal can't satisfy the +// brand directly. The actual canonicalization invariant is irrelevant for +// these tests (we never feed the entry back into the schema); they only +// need the discriminator + a stable physical path. const externalEntry: FileEntry = { id: '019606a0-0000-7000-8000-0000000000ee' as FileEntryId, origin: 'external', diff --git a/src/main/services/file/internal/content/__tests__/read.test.ts b/src/main/services/file/internal/content/__tests__/read.test.ts index 22232282156..50b2ed51693 100644 --- a/src/main/services/file/internal/content/__tests__/read.test.ts +++ b/src/main/services/file/internal/content/__tests__/read.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { fileEntryTable } from '@data/db/schemas/file' -import type { CanonicalExternalPath, FileEntryId } from '@shared/data/types/file' +import type { FileEntryId } from '@shared/data/types/file' import type { FilePath } from '@shared/types/file' import { setupTestDatabase } from '@test-helpers/db' import { MockMainDbServiceUtils } from '@test-mocks/main/DbService' @@ -157,12 +157,4 @@ describe('internal/content/read', () => { const result = await readByPath(deps, file) expect(result.content).toBe('direct content') }) - - it('proves CanonicalExternalPath brand is unused (read uses raw FilePath)', () => { - // Sanity: external-path lookup goes through fileEntryService.findByExternalPath, - // not through internal/content/read. This test exists to prevent accidental - // signature drift between modules. - const _brand: CanonicalExternalPath = '/tmp/x' as CanonicalExternalPath - expect(typeof _brand).toBe('string') - }) }) diff --git a/src/main/services/file/internal/entry/__tests__/create.test.ts b/src/main/services/file/internal/entry/__tests__/create.test.ts index 2a11f1e3e0f..db9a4fe1ef7 100644 --- a/src/main/services/file/internal/entry/__tests__/create.test.ts +++ b/src/main/services/file/internal/entry/__tests__/create.test.ts @@ -3,7 +3,7 @@ import type { Server } from 'node:http' import { tmpdir } from 'node:os' import path from 'node:path' -import type { FilePath } from '@shared/types/file' +import { type FilePath, FilePathSchema } from '@shared/types/file' import { setupTestDatabase } from '@test-helpers/db' import { MockMainDbServiceUtils } from '@test-mocks/main/DbService' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -319,20 +319,28 @@ describe('internal/entry/create.createInternal', () => { // Regression guard: previously `name = params.name ?? defaultNameFromPath(params.externalPath)` // and `ext = extWithoutDot(params.externalPath)` derived from the raw // input. On macOS APFS the raw input can arrive in NFD form while - // `canonical` is NFC — persisting NFD-encoded name/ext alongside an + // the canonical form is NFC — persisting NFD-encoded name/ext alongside an // NFC externalPath silently breaks `path.basename(canonical) === entry.name` - // equality checks. The fix derives every field from `canonical`. - const nfdName = 'qué' // 'qué' = e + combining acute (NFD) - const nfcName = 'qué' // 'qué' = single codepoint (NFC) + // equality checks. The fix derives every field from the canonical path. + // + // Canonicalization now happens once, at the `FilePathSchema.parse()` boundary + // (IPC schema in production), not inside `ensureExternal` itself — so this test + // builds `externalPath` via `FilePathSchema.parse(file)` to model exactly what a + // real caller hands to `ensureExternal`: an already-canonical `FilePath`. + // + // ASCII \u escapes (not raw accented literals) so formatter/editor tooling + // cannot silently re-normalize the NFD form and turn this into a tautology. + const nfdName = 'qu\u0065\u0301' // q, u, e, combining acute -> NFD + const nfcName = 'qu\u00E9' // q, u, e-precomposed -> NFC expect(nfdName).not.toBe(nfcName) // byte-distinct strings expect(nfdName.normalize('NFC')).toBe(nfcName) const file = path.join(tmp, `${nfdName}.txt`) await writeFile(file, 'x') - const entry = await ensureExternal(deps, { externalPath: file as FilePath }) + const entry = await ensureExternal(deps, { externalPath: FilePathSchema.parse(file) }) if (entry.origin !== 'external') throw new Error('expected external entry') - // The stored externalPath is NFC (canonicalize applies .normalize('NFC')). + // The stored externalPath is NFC (FilePathSchema applies .normalize('NFC')). const canonical = entry.externalPath expect(canonical.normalize('NFC')).toBe(canonical) // name must derive from the canonical (NFC) basename, not the raw NFD input. diff --git a/src/main/services/file/internal/entry/__tests__/lifecycle.test.ts b/src/main/services/file/internal/entry/__tests__/lifecycle.test.ts index c32129e8499..1493d478f02 100644 --- a/src/main/services/file/internal/entry/__tests__/lifecycle.test.ts +++ b/src/main/services/file/internal/entry/__tests__/lifecycle.test.ts @@ -135,10 +135,10 @@ describe('internal/entry/lifecycle', () => { const entry = fileEntryService.getById(id) if (entry.origin !== 'external') throw new Error('expected external entry') const userFile = entry.externalPath - expect(await exists(userFile as FilePath)).toBe(true) + expect(await exists(userFile)).toBe(true) await permanentDelete(deps, id) expect(fileEntryService.findById(id)).toBeNull() - expect(await exists(userFile as FilePath)).toBe(true) + expect(await exists(userFile)).toBe(true) }) it('still deletes the row when the internal physical file is missing', async () => { diff --git a/src/main/services/file/internal/entry/create.ts b/src/main/services/file/internal/entry/create.ts index 387ef47adef..33137eeff2c 100644 --- a/src/main/services/file/internal/entry/create.ts +++ b/src/main/services/file/internal/entry/create.ts @@ -16,12 +16,11 @@ import { application } from '@application' import { loggerService } from '@logger' import { atomicWriteFile, copy as fsCopy, download, remove as fsRemove, stat as fsStat } from '@main/utils/file/fs' import type { FileEntry } from '@shared/data/types/file' -import type { FilePath } from '@shared/types/file' +import { type FilePath, FilePathSchema } from '@shared/types/file' import mime from 'mime' import { v7 as uuidv7 } from 'uuid' import type { CreateInternalEntryParams, EnsureExternalEntryParams } from '../../FileManager' -import { canonicalizeExternalPath } from '../../utils/pathResolver' import type { FileManagerDeps } from '../deps' const logger = loggerService.withContext('internal/entry/create') @@ -139,7 +138,7 @@ export async function createInternal(deps: FileManagerDeps, params: CreateIntern const source = normaliseSource(params) const id = uuidv7() const filename = `${id}${source.ext ? `.${source.ext}` : ''}` - const physical = application.getPath('feature.files.data', filename) as FilePath + const physical = FilePathSchema.parse(application.getPath('feature.files.data', filename)) await source.writeTo(physical) let stats try { @@ -165,27 +164,16 @@ export async function createInternal(deps: FileManagerDeps, params: CreateIntern /** * Ensure an entry exists for a user-provided absolute path. Pure upsert keyed - * by canonicalized externalPath. Path existence is verified via `fs.stat` - * before insert; ENOENT propagates. + * by `params.externalPath`, which is already a canonical `FilePath` — the + * `FilePathSchema` brand at the IPC boundary is the sole canonicalization + * step, so this function derives every downstream value directly from it + * without re-canonicalizing. Path existence is verified via `fs.stat` before + * insert; ENOENT propagates. */ export async function ensureExternal(deps: FileManagerDeps, params: EnsureExternalEntryParams): Promise { - const canonical = canonicalizeExternalPath(params.externalPath) - const existing = deps.fileEntryService.findByExternalPath(canonical) + const existing = deps.fileEntryService.findByExternalPath(params.externalPath) if (existing) return existing - // Every downstream derivation must consume the canonical path, not the - // raw `params.externalPath`. On macOS APFS the raw input can arrive in - // NFD form while `canonical` is NFC; deriving `name` / `ext` from raw - // would persist NFD-encoded values alongside an NFC `externalPath`, so - // a later strict-equality check like `path.basename(canonical) === entry.name` - // would silently diverge. Same risk for trailing-separator / `..` - // noise in the raw input. - // `canonical` is `CanonicalExternalPath`; the schema-side S5 refine now - // makes the BO's `externalPath` `FilePath & CanonicalExternalPath`, but - // here we only hold the factory-side `CanonicalExternalPath`. The cast - // to `FilePath` is the sanctioned service-boundary upcast — the - // canonicalize pipeline already enforces the absolute-shape gate that - // `FilePath` represents at the type level. - await fsStat(canonical as unknown as FilePath) + await fsStat(params.externalPath) // Case-insensitive peer lookup is index-backed via the // `fe_external_path_lower_unique_idx` functional UNIQUE on `lower(externalPath)`. // The same index hard-rejects an INSERT that would collide with an existing @@ -200,19 +188,18 @@ export async function ensureExternal(deps: FileManagerDeps, params: EnsureExtern // on-disk canonical string → same logical file, reuse the existing peer. // On case-sensitive filesystems the two paths resolve to distinct strings // (or one ENOENTs) → genuine distinct files, throw with peer info so the - // caller can decide (rename / surface to user). This is the `fs.realpath` - // upgrade pre-announced in `canonicalizeExternalPath`'s JSDoc. + // caller can decide (rename / surface to user). // // SELECT failure (transient DB lock, connection drop) propagates; the // subsequent INSERT would fail at the same boundary with a more // diagnosable stack, so wrapping in try/catch here only hides the real // error one stack frame earlier. - const peers = deps.fileEntryService.findCaseInsensitivePeers(canonical) + const peers = deps.fileEntryService.findCaseInsensitivePeers(params.externalPath) if (peers.length > 0) { - const reusable = await resolveCaseCollisionPeer(canonical as FilePath, peers) + const reusable = await resolveCaseCollisionPeer(params.externalPath, peers) if (reusable) { logger.info('ensureExternal: reusing case-collision peer (fs.realpath confirmed same FS entry)', { - newPath: canonical, + newPath: params.externalPath, peerId: reusable.id, peerPath: (reusable as { externalPath: string }).externalPath }) @@ -227,31 +214,31 @@ export async function ensureExternal(deps: FileManagerDeps, params: EnsureExtern // uniqueness is what option (c) brings. throw new Error( `ensureExternal: case-collision with existing entries — fs.realpath confirms different FS entities. ` + - `New: ${canonical}; conflicting peers: ${peers + `New: ${params.externalPath}; conflicting peers: ${peers .map((p) => `${p.id}=${(p as { externalPath: string }).externalPath}`) .join(', ')}` ) } - // `name` and `ext` are pure projections of `canonical` — derived here, + // `name` and `ext` are pure projections of `externalPath` — derived here, // not accepted from callers. Doc-stated invariant: "external `name` is a // pure projection of `externalPath`" (file-manager-architecture §1.5 + // architecture §3.3) is now enforced by the IPC type lacking a `name` // override field. Phase 2 consumers that want a different display name // must `rename` after `ensureExternalEntry` returns. - const name = defaultNameFromPath(canonical) - const ext = extWithoutDot(canonical) + const name = defaultNameFromPath(params.externalPath) + const ext = extWithoutDot(params.externalPath) const inserted = deps.fileEntryService.create({ origin: 'external', name, ext, - externalPath: canonical + externalPath: params.externalPath }) // Reverse-index hook: subsequent watcher / opportunistic ops events for - // `canonical` should reach this entry id. The fs.stat above succeeded — - // record a fresh 'present' observation so any imminent UI query short- - // circuits the cold-stat path. - deps.danglingCache.addEntry(inserted.id, canonical as FilePath) - deps.danglingCache.onFsEvent(canonical as FilePath, 'present', 'ops') + // `params.externalPath` should reach this entry id. The fs.stat above + // succeeded — record a fresh 'present' observation so any imminent UI + // query short-circuits the cold-stat path. + deps.danglingCache.addEntry(inserted.id, params.externalPath) + deps.danglingCache.onFsEvent(params.externalPath, 'present', 'ops') return inserted } diff --git a/src/main/services/file/internal/entry/rename.ts b/src/main/services/file/internal/entry/rename.ts index 98beec00f11..dbeaf2989b8 100644 --- a/src/main/services/file/internal/entry/rename.ts +++ b/src/main/services/file/internal/entry/rename.ts @@ -14,9 +14,8 @@ import { loggerService } from '@logger' import { exists, isSameFile, move as fsMove } from '@main/utils/file/fs' import type { FileEntry, FileEntryId } from '@shared/data/types/file' import { SafeNameSchema } from '@shared/data/types/file' -import type { FilePath } from '@shared/types/file' +import { FilePathSchema } from '@shared/types/file' -import { canonicalizeExternalPath } from '../../utils/pathResolver' import type { FileManagerDeps } from '../deps' const logger = loggerService.withContext('internal/entry/rename') @@ -34,7 +33,7 @@ export async function rename(deps: FileManagerDeps, id: FileEntryId, newName: st return deps.fileEntryService.update(id, { name: newName }) } // entry.origin === 'external' from here on; the schema discriminator - // guarantees externalPath is present as `AbsolutePathSchema` (no `null` + // guarantees externalPath is present as `FilePathSchema` (no `null` // branch exists on ExternalEntrySchema), so the prior defensive // `if (!entry.externalPath)` throw was unreachable and has been removed. const dir = path.dirname(entry.externalPath) @@ -44,7 +43,7 @@ export async function rename(deps: FileManagerDeps, id: FileEntryId, newName: st // canonical (written through `ensureExternalEntry`), so string equality // here is a reliable "same logical path" test. const oldPath = entry.externalPath - const target = canonicalizeExternalPath(path.join(dir, `${newName}${ext}`)) + const target = FilePathSchema.parse(path.join(dir, `${newName}${ext}`)) // Defense in depth: `SafeNameSchema.parse(newName)` above already rejects // path separators and `..`, but a future regression in the schema (or any // canonicalization behaviour change) must not be able to relocate the @@ -55,17 +54,17 @@ export async function rename(deps: FileManagerDeps, id: FileEntryId, newName: st if (target === entry.externalPath) { return entry } - if (await exists(target as FilePath)) { + if (await exists(target)) { // On case-insensitive filesystems (macOS APFS / Windows NTFS) a // `Foo.pdf → foo.pdf` rename hits this branch because `exists` reports // the file under its on-disk case. If both paths resolve to the same // inode it's a legitimate case-only rename — fall through to `fsMove`, // which the OS treats as an in-place case fix. - if (!(await isSameFile(target as FilePath, oldPath))) { + if (!(await isSameFile(target, oldPath))) { throw new Error(`rename: target path already exists: ${target}`) } } - await fsMove(oldPath, target as FilePath) + await fsMove(oldPath, target) const canonical = target // Single atomic DB write — `setExternalPathAndName` is the only sanctioned // mutation site for `externalPath`. Doing both column changes in one @@ -86,7 +85,7 @@ export async function rename(deps: FileManagerDeps, id: FileEntryId, newName: st renamed = deps.fileEntryService.setExternalPathAndName(id, canonical, newName) } catch (dbErr) { try { - await fsMove(canonical as FilePath, oldPath) + await fsMove(canonical, oldPath) } catch (rollbackErr) { logger.warn('rename: FS-DB skew — file moved to target but DB update failed, and rollback failed', { id, @@ -107,7 +106,7 @@ export async function rename(deps: FileManagerDeps, id: FileEntryId, newName: st // Reverse-index swap. The old path is fully invalidated; the new path // takes over with a fresh 'present' observation since fsMove just succeeded. deps.danglingCache.removeEntry(id, oldPath) - deps.danglingCache.addEntry(id, canonical as FilePath) - deps.danglingCache.onFsEvent(canonical as FilePath, 'present', 'ops') + deps.danglingCache.addEntry(id, canonical) + deps.danglingCache.onFsEvent(canonical, 'present', 'ops') return renamed } diff --git a/src/main/services/file/toFileInfo.ts b/src/main/services/file/toFileInfo.ts index 8c8aef511ea..ad37bea56cc 100644 --- a/src/main/services/file/toFileInfo.ts +++ b/src/main/services/file/toFileInfo.ts @@ -50,5 +50,5 @@ export async function toFileInfo(entry: FileEntry): Promise { type: getFileTypeByExt(ext ?? ''), createdAt: s.createdAt || s.modifiedAt, modifiedAt: s.modifiedAt - }) as FileInfo + }) } diff --git a/src/main/services/file/tree/DirectoryTreeManager.ts b/src/main/services/file/tree/DirectoryTreeManager.ts index b4f61f11ccc..05f2199c7a4 100644 --- a/src/main/services/file/tree/DirectoryTreeManager.ts +++ b/src/main/services/file/tree/DirectoryTreeManager.ts @@ -27,7 +27,7 @@ import { randomUUID } from 'node:crypto' import { loggerService } from '@logger' import { BaseService, type Disposable, Injectable, Phase, ServicePhase } from '@main/core/lifecycle' -import { AbsolutePathSchema } from '@shared/data/types/file' +import { FilePathSchema } from '@shared/data/types/file' import { IpcChannel } from '@shared/IpcChannel' import { type CreateTreeIpcResult, @@ -45,7 +45,7 @@ import { createDirectoryTree, type DirectoryTreeBuilder } from './builder' // next to the handlers, matching the FileManager / DataApi convention where // leaf schemas live in shared and per-channel param schemas live in main. const TreeCreateParamsSchema = z.strictObject({ - rootPath: AbsolutePathSchema, + rootPath: FilePathSchema, options: DirectoryTreeOptionsSchema.optional() }) @@ -53,8 +53,8 @@ const TreeDisposeParamsSchema = z.strictObject({ treeId: z.string().min(1) }) const TreeRenameParamsSchema = z.strictObject({ treeId: z.string().min(1), - oldPath: AbsolutePathSchema, - newPath: AbsolutePathSchema + oldPath: FilePathSchema, + newPath: FilePathSchema }) /** diff --git a/src/main/services/file/utils/__tests__/pathResolver.test.ts b/src/main/services/file/utils/__tests__/pathResolver.test.ts index 7aff595ee25..0a6493e1a2a 100644 --- a/src/main/services/file/utils/__tests__/pathResolver.test.ts +++ b/src/main/services/file/utils/__tests__/pathResolver.test.ts @@ -8,12 +8,10 @@ vi.mock('@application', async () => { return mockApplicationFactory() }) -import path from 'node:path' - -import type { CanonicalExternalPath } from '@shared/data/types/file' +import type { FilePath } from '@shared/types/file' import type { PathResolvableEntry } from '../pathResolver' -import { canonicalizeExternalPath, getExtSuffix, resolvePhysicalPath } from '../pathResolver' +import { getExtSuffix, resolvePhysicalPath } from '../pathResolver' describe('getExtSuffix', () => { it('returns dot-prefixed extension for non-null ext', () => { @@ -53,20 +51,10 @@ describe('resolvePhysicalPath', () => { id: '019606a0-0000-7000-8000-000000000001', origin: 'external', ext: 'md', - externalPath: '/Users/me/notes/readme.md' + externalPath: '/Users/me/notes/readme.md' as FilePath } expect(resolvePhysicalPath(entry)).toBe('/Users/me/notes/readme.md') }) - - it('resolves path (normalizes relative segments)', () => { - const entry: PathResolvableEntry = { - id: '019606a0-0000-7000-8000-000000000002', - origin: 'external', - ext: 'pdf', - externalPath: '/Users/me/./docs/../docs/report.pdf' - } - expect(resolvePhysicalPath(entry)).toBe('/Users/me/docs/report.pdf') - }) }) describe('security', () => { @@ -87,46 +75,5 @@ describe('resolvePhysicalPath', () => { } expect(() => resolvePhysicalPath(entry)).toThrow('null bytes') }) - - it('rejects null bytes in externalPath', () => { - const entry: PathResolvableEntry = { - id: '019606a0-0000-7000-8000-000000000001', - origin: 'external', - ext: 'md', - externalPath: '/Users/me/evil\0path.md' - } - expect(() => resolvePhysicalPath(entry)).toThrow('null bytes') - }) - }) -}) - -describe('canonicalizeExternalPath', () => { - it('resolves "." and ".." segments', () => { - const input = path.resolve('/foo/./bar/../baz') - expect(canonicalizeExternalPath(input) as string).toBe(path.resolve('/foo/baz')) - }) - - it('NFC-normalizes Unicode (NFD → NFC)', () => { - const nfd = '/users/Müller' - const nfc = '/users/Müller' - expect(canonicalizeExternalPath(nfd) as string).toBe(canonicalizeExternalPath(nfc) as string) - }) - - it('strips trailing path separator', () => { - expect(canonicalizeExternalPath('/foo/bar/') as string).toBe(canonicalizeExternalPath('/foo/bar') as string) - }) - - it('preserves a path that is already canonical', () => { - const canonical = path.resolve('/foo/bar/baz.txt') - expect(canonicalizeExternalPath(canonical) as string).toBe(canonical) - }) - - it('rejects null bytes', () => { - expect(() => canonicalizeExternalPath('/foo/bar\0/baz')).toThrow(/null byte/i) - }) - - it('returns a CanonicalExternalPath brand (compile-time check)', () => { - const canonical: CanonicalExternalPath = canonicalizeExternalPath('/foo') - expect(typeof canonical).toBe('string') }) }) diff --git a/src/main/services/file/utils/pathResolver.ts b/src/main/services/file/utils/pathResolver.ts index c68eb69ba41..acd044707de 100644 --- a/src/main/services/file/utils/pathResolver.ts +++ b/src/main/services/file/utils/pathResolver.ts @@ -1,10 +1,6 @@ -import path from 'node:path' - import { application } from '@application' import { loggerService } from '@logger' -import type { CanonicalExternalPath } from '@shared/data/types/file' -import type { FilePath } from '@shared/types/file' -import { canonicalizeAbsolutePath } from '@shared/utils/file/canonicalize' +import { type FilePath, FilePathSchema } from '@shared/types/file' const logger = loggerService.withContext('pathResolver') @@ -16,7 +12,7 @@ const logger = loggerService.withContext('pathResolver') */ export type PathResolvableEntry = | { id: string; origin: 'internal'; ext: string | null } - | { id: string; origin: 'external'; ext: string | null; externalPath: string } + | { id: string; origin: 'external'; ext: string | null; externalPath: FilePath } /** * Get the file extension suffix (with dot) or empty string if null. @@ -32,15 +28,15 @@ export function getExtSuffix(ext: string | null): string { * - `origin='external'` → `externalPath` directly (user-provided absolute path) * * Returns a branded `FilePath` so callers can pass the result straight to - * `@main/utils/file/fs` without a manual `as FilePath` cast — the brand - * is sanctioned here because the function ran the null-byte + absolute-path - * guards that make a string safe to treat as a `FilePath`. + * `@main/utils/file/fs` without a manual `as FilePath` cast. * * @throws If null bytes are detected (potential path-truncation attack) in - * entry id / ext / externalPath. Security-sensitive rejections are logged - * at `error` level — these inputs should never reach the resolver if - * upstream Zod validation runs; arriving here indicates either a - * parse-bypass or a data integrity problem worth investigating. + * entry id / ext. Security-sensitive rejections are logged at `error` + * level — these inputs should never reach the resolver if upstream Zod + * validation runs; arriving here indicates either a parse-bypass or a data + * integrity problem worth investigating. The external branch cannot throw + * this way: `entry.externalPath` is already a `FilePath`, proven canonical + * (including null-byte rejection) by `FilePathSchema` at parse time. */ export function resolvePhysicalPath(entry: PathResolvableEntry): FilePath { // Reject null bytes in any user-controlled path segments (path-truncation guard). @@ -50,108 +46,11 @@ export function resolvePhysicalPath(entry: PathResolvableEntry): FilePath { } if (entry.origin === 'internal') { - return application.getPath('feature.files.data', `${entry.id}${getExtSuffix(entry.ext)}`) as FilePath + return FilePathSchema.parse(application.getPath('feature.files.data', `${entry.id}${getExtSuffix(entry.ext)}`)) } - // entry.origin === 'external' — schema discriminator guarantees externalPath - // is present (no null branch on ExternalEntrySchema), so the prior - // defensive null-check is unreachable and has been removed. - if (entry.externalPath.includes('\0')) { - logger.error('Null byte detected in externalPath', { entryId: entry.id }) - throw new Error(`external entry ${entry.id} externalPath contains null bytes`) - } - return path.resolve(entry.externalPath) as FilePath -} - -/** - * Canonicalize a user-provided external path into the form stored in - * `file_entry.externalPath`. This result is the **sole** key used for upsert - * and lookup of external entries — `ensureExternalEntry` MUST call this - * before any DB write or query, and `fileEntryService.findByExternalPath` - * does the same at read boundaries. - * - * The return type is branded as `CanonicalExternalPath` so that downstream - * surfaces filtering by `externalPath` (today: `findByExternalPath`; in future: - * any new DataApi / service query on this column) cannot accept a raw user - * path by mistake. New call sites gain compile-time guarding for free. - * - * ───────────────────────────────────────────────────────────────────────── - * ⚠️ RULE-EVOLUTION DISCIPLINE - * ───────────────────────────────────────────────────────────────────────── - * Modifying the normalization behavior of this function (adding / removing / - * altering any step below) REQUIRES a paired Drizzle migration that - * re-canonicalizes every existing `file_entry` row where `origin='external'`. - * - * Rationale: the canonical form is application-layer logic, not DB schema. - * Existing rows were written under the **old** rule; new queries run under - * the **new** rule. Without a re-canonicalization migration, historical rows - * remain under the old rule and silently stop matching `findByExternalPath` - * lookups — users experience "my file is in the library but the system says - * it isn't". - * - * When a new rule also collapses previously-distinct strings to the same - * canonical form (e.g. `fs.realpath` merging case-insensitive duplicates), - * the migration additionally MUST merge the colliding rows. The winner - * selection and association-row re-pointing rules are defined in - * `docs/references/file/file-manager-architecture.md §1.2 Rule evolution - * discipline` — follow them exactly; do not improvise. - * ───────────────────────────────────────────────────────────────────────── - * - * ## Scope (this function's contract) - * - * Cheap, synchronous normalization — no FS IO, cross-platform uniform: - * - * 0. Reject null bytes (`\0`) — a null byte in the raw path is never a - * legal filesystem path; rejecting at canonicalization keeps the rest - * of the pipeline (ensureExternalEntry → findByExternalPath → - * resolvePhysicalPath) free to treat canonical paths as null-byte-free. - * Without this step, a malformed path could be persisted into - * `file_entry.externalPath` and only blow up later at use-time inside - * `resolvePhysicalPath`, leaving a poisoned row in the DB. - * 1. `path.resolve(raw)` — make absolute, resolve `./` and `../` - * 2. Unicode NFC normalization — defends against NFC-vs-NFD mismatch - * (macOS filesystems can surface either form depending on API; this - * is the most common duplicate-entry trigger for CJK-filename users) - * 3. Strip trailing separator — `/foo/bar/` → `/foo/bar` - * - * This subset covers the dominant sources of `externalPath` inputs in Cherry - * (Electron `showOpenDialog` and drag-drop, both of which return OS-canonical - * paths with correct case already). - * - * ## Deliberately NOT handled here - * - * The following are handled elsewhere, NOT by this function — `canonicalizeExternalPath` - * stays sync / FS-IO-free so it can run from Zod refines and read paths without - * paying `fs.*` cost on every call. - * - * - **Case-insensitive FS de-duplication** (macOS APFS / Windows NTFS): - * `/Users/me/FILE.pdf` vs `/Users/me/file.pdf` are byte-distinct after - * canonicalize and would still produce two rows under this function alone. - * Enforced at two layers downstream: (1) DB functional unique index - * `UNIQUE(lower(externalPath))` rejects the second row at INSERT, and - * (2) `ensureExternalEntry` runs `fs.realpath` on the colliding pair - * before INSERT and either reuses the existing row (same on-disk entity, - * case-insensitive FS) or throws `case-collision` (distinct files, - * case-sensitive FS). See `file-manager-architecture.md §1.2 Duplicate-entry - * detection on insert`. - * - **Symlink resolution** (`realpath` target collapse): two symlinks to the - * same file remain distinct entries. Not enforced anywhere — symlink - * collapse would require unconditional `fs.realpath` on every canonicalize, - * trading the sync/cheap contract this function commits to. - * - **Windows short-name (8.3) resolution**: `LONGNA~1` vs `longname` — - * requires WinAPI; low-priority edge case. - * - **SMB / NFS mounts with FS-level case-sensitivity diverging from host**: - * out of scope; document as known limitation. - * - * - * @param raw user-provided absolute (or resolvable) path - * @returns canonical form stored in `file_entry.externalPath` - * @throws if `raw` contains null bytes - */ -export function canonicalizeExternalPath(raw: string): CanonicalExternalPath { - // Delegate to the shared pure-JS implementation so the FileEntry schema - // can `refine` against the exact same rule on parse (S5). The brand cast - // here is the sanctioned production-side factory site documented in the - // `CanonicalExternalPath` JSDoc. - return canonicalizeAbsolutePath(raw) as CanonicalExternalPath + // entry.origin === 'external' — externalPath is already a canonical + // FilePath (branded at parse time by FilePathSchema), so no further + // normalization or null-byte check is needed here. + return entry.externalPath } diff --git a/src/main/services/file/watcher/__tests__/watcher.test.ts b/src/main/services/file/watcher/__tests__/watcher.test.ts index 8d64890ae9d..dc096c92bc3 100644 --- a/src/main/services/file/watcher/__tests__/watcher.test.ts +++ b/src/main/services/file/watcher/__tests__/watcher.test.ts @@ -200,8 +200,9 @@ describe('createDirectoryWatcher', () => { const writtenPath = path.join(dir, nfd) as FilePath const canonicalPath = path.join(dir, nfc) as FilePath - // DanglingCache's reverse index is populated by `ensureExternalEntry` → - // `canonicalizeExternalPath` which already lands NFC. Mirror that here. + // DanglingCache's reverse index is populated by `ensureExternalEntry`, + // whose `externalPath` is already NFC-canonical via `FilePathSchema`. + // Mirror that here. danglingCache.addEntry('e-w-nfd' as FileEntryId, canonicalPath) const w = createDirectoryWatcher(dir as FilePath) diff --git a/src/main/services/file/watcher/index.ts b/src/main/services/file/watcher/index.ts index 000a8c39c6c..358d1f85978 100644 --- a/src/main/services/file/watcher/index.ts +++ b/src/main/services/file/watcher/index.ts @@ -162,8 +162,9 @@ class DirectoryWatcherImpl implements DirectoryWatcher { * cache doesn't track. * * The cache feed is keyed by canonical (NFC) path because `DanglingCache`'s - * reverse index is populated by `ensureExternalEntry` → `canonicalizeExternalPath` - * (NFC). chokidar emits whatever the OS hands it; on macOS APFS that is NFD + * reverse index is populated by `ensureExternalEntry`, whose `externalPath` + * is already NFC-canonical via `FilePathSchema`. chokidar emits whatever the + * OS hands it; on macOS APFS that is NFD * for CJK / accented filenames migrated from HFS+ (or written by tools like * `rsync -E` that preserve the source encoding). Without normalizing here * the `path → entryIds` lookup misses and the cache stays stale. The diff --git a/src/renderer/pages/files/FilesPage.tsx b/src/renderer/pages/files/FilesPage.tsx index f0c49f0c22d..49d5ce508fe 100644 --- a/src/renderer/pages/files/FilesPage.tsx +++ b/src/renderer/pages/files/FilesPage.tsx @@ -20,7 +20,8 @@ import { safeOpen } from '@renderer/utils/file/safeOpen' import { isMac } from '@renderer/utils/platform' import type { FileEntry, FileEntryId } from '@shared/data/types/file' import type { OutputFor } from '@shared/ipc/types' -import type { FilePath, FileType } from '@shared/types/file' +import type { FileType } from '@shared/types/file' +import { FilePathSchema } from '@shared/types/file' import { createFileEntryHandle, getFileTypeByExt } from '@shared/utils/file' import { toSafeFileUrl } from '@shared/utils/file/url' import { MoreHorizontal, Upload } from 'lucide-react' @@ -123,7 +124,7 @@ async function requestBatchedInternalEntryCreates(paths: readonly string[]): Pro const results = await Promise.all( chunks.map((chunk) => ipcApi.request('file.batch_create_internal_entries', { - items: chunk.map((path) => ({ source: 'path' as const, path })) + items: chunk.map((path) => ({ source: 'path' as const, path: FilePathSchema.parse(path) })) }) ) ) @@ -195,7 +196,7 @@ function toFileItem( ...base, ...originFields, type, - previewUrl: physicalPath ? toSafeFileUrl(physicalPath as FilePath, entry.ext) : undefined + previewUrl: physicalPath ? toSafeFileUrl(physicalPath, entry.ext) : undefined } } diff --git a/src/renderer/pages/paintings/components/__tests__/PaintingComposerSwitch.test.tsx b/src/renderer/pages/paintings/components/__tests__/PaintingComposerSwitch.test.tsx index 7fe8d802388..91c29b59484 100644 --- a/src/renderer/pages/paintings/components/__tests__/PaintingComposerSwitch.test.tsx +++ b/src/renderer/pages/paintings/components/__tests__/PaintingComposerSwitch.test.tsx @@ -1,5 +1,6 @@ import type { ComposerSurfaceProps } from '@renderer/components/composer/ComposerSurface' import type { FileEntry } from '@shared/data/types/file/fileEntry' +import type { FilePath } from '@shared/types/file' import { render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -58,8 +59,8 @@ describe('PaintingComposer painting switch', () => { ...window.api, file: { ...window.api.file, - getPhysicalPath: vi.fn(async ({ id }: { id: string }) => `/p/${id}.png`), - createInternalEntry: vi.fn(async ({ path }: { path: string }) => makeEntry(path)) + getPhysicalPath: vi.fn(async ({ id }: { id: string }) => `/p/${id}.png` as FilePath), + createInternalEntry: vi.fn(async ({ path }: { path: FilePath }) => makeEntry(path)) } } as typeof window.api }) diff --git a/src/renderer/pages/paintings/hooks/__tests__/usePaintingComposerInputFiles.test.ts b/src/renderer/pages/paintings/hooks/__tests__/usePaintingComposerInputFiles.test.ts index 899038ce2b0..f399780432c 100644 --- a/src/renderer/pages/paintings/hooks/__tests__/usePaintingComposerInputFiles.test.ts +++ b/src/renderer/pages/paintings/hooks/__tests__/usePaintingComposerInputFiles.test.ts @@ -1,5 +1,6 @@ import type { ComposerAttachment } from '@renderer/utils/message/composerAttachment' import type { FileEntry } from '@shared/data/types/file/fileEntry' +import type { FilePath } from '@shared/types/file' import { act, renderHook, waitFor } from '@testing-library/react' import { useState } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -21,8 +22,8 @@ const makeAttachment = (sourceId: string, path: string): ComposerAttachment => ( describe('usePaintingComposerInputFiles', () => { beforeEach(() => { - const getPhysicalPath = vi.fn(async (params: { id: string }) => `/p/${params.id}.png`) - const createInternalEntry = vi.fn(async (params: { path: string }) => + const getPhysicalPath = vi.fn(async (params: { id: string }) => `/p/${params.id}.png` as FilePath) + const createInternalEntry = vi.fn(async (params: { path: FilePath }) => makeEntry(params.path.includes('new') ? 'fe-new' : 'fe-x') ) window.api = { diff --git a/src/renderer/utils/knowledgeFileEntry.ts b/src/renderer/utils/knowledgeFileEntry.ts index dfb1063d2f8..30cfc502476 100644 --- a/src/renderer/utils/knowledgeFileEntry.ts +++ b/src/renderer/utils/knowledgeFileEntry.ts @@ -1,9 +1,10 @@ import type { FileMetadata } from '@renderer/types/file' -import { AbsolutePathSchema } from '@shared/data/types/file' +import { FilePathSchema } from '@shared/data/types/file' +import type { FilePath } from '@shared/types/file' export interface KnowledgeFileItemData { source: string - path: string + path: FilePath } export const resolveKnowledgeFileData = async ( @@ -16,13 +17,14 @@ export const resolveKnowledgeFileData = async ( throw new Error(`Failed to resolve a local path for "${displayName}"`) } - if (!AbsolutePathSchema.safeParse(source).success) { + const result = FilePathSchema.safeParse(source) + if (!result.success) { throw new Error(`Failed to resolve an absolute local path for "${displayName}"`) } return { source, - path: source + path: result.data } } diff --git a/src/shared/data/types/file/fileEntry.ts b/src/shared/data/types/file/fileEntry.ts index a146b014854..90ccf59471e 100644 --- a/src/shared/data/types/file/fileEntry.ts +++ b/src/shared/data/types/file/fileEntry.ts @@ -98,9 +98,7 @@ * unmanaged `@main/utils/file/fs.remove(path)` separately. */ -import type { FilePath } from '@shared/types/file/common' -import { SafeExtSchema } from '@shared/types/file/common' -import { canonicalizeAbsolutePath } from '@shared/utils/file/canonicalize' +import { FilePathSchema, SafeExtSchema } from '@shared/types/file/common' import * as z from 'zod' import { SafeNameSchema, TimestampSchema } from './essential' @@ -125,87 +123,6 @@ export type FileEntryId = z.infer export const FileEntryOriginSchema = z.enum(['internal', 'external']) export type FileEntryOrigin = z.infer -// ─── Absolute Path ─── - -/** - * Absolute filesystem path (Unix or Windows). Rejects `file://` URLs — use a - * dedicated URL schema if needed. - * - * **Storage invariant for `externalPath`**: values persisted in - * `file_entry.externalPath` must be the output of - * `canonicalizeExternalPath()` — currently `path.resolve` + Unicode NFC + - * trailing-separator strip. Zod cannot enforce this shape - * at the schema level; `ensureExternalEntry` and `fileEntryService.findByExternalPath` - * are the application-layer enforcement points. See `pathResolver.ts` for - * the full contract, including deliberately deferred normalization steps - * (case-insensitive FS dedupe, symlink target resolution). - */ -export const AbsolutePathSchema = z - .string() - .min(1) - .refine((s) => !s.includes('\0'), 'externalPath must not contain null bytes') - .refine((s) => s.startsWith('/') || /^[A-Za-z]:\\/.test(s), 'externalPath must be an absolute filesystem path') - -// ─── Canonical External Path (TS phantom brand) ─── - -/** - * A `string` already processed through `canonicalizeExternalPath`. - * - * This is a **TypeScript-only phantom brand** (zero runtime cost, zero wire - * cost) that acts as a compile-time guard for every DB read/write surface on - * `externalPath`: any query entry point that filters by `externalPath` MUST - * narrow its input to this type, which forces callers through - * `canonicalizeExternalPath()` instead of accepting a raw user path. - * - * ## Why a brand and not runtime validation - * - * The correctness invariant — "the string equals `canonicalizeExternalPath(x)` - * for some `x`" — cannot be verified at runtime without re-running - * canonicalization, which would defeat the purpose. The brand expresses - * "this value was produced by the authorized factory" structurally, so the - * type system (not runtime checks) enforces the contract. - * - * ## Authorized construction - * - * - **Production code**: only `canonicalizeExternalPath()` in - * `src/main/services/file/utils/pathResolver.ts` may produce values of this type. - * Other production code importing `CanonicalExternalPath` MUST receive it - * from that function (directly or transitively) — never via `as` cast. - * - **Tests and fixtures**: may cast known-canonical string literals with - * `'/abs/path' as CanonicalExternalPath` for readability. - * - **DB rows**: the `externalPath` column is typed as `string | null` in - * Drizzle (SQLite has no brand concept); upcasting into - * `CanonicalExternalPath` at the service boundary is acceptable because - * writes on that column already go through the canonicalization path. - */ -// String-literal brand rather than a `unique symbol`: a `unique symbol` brand -// (named or inline) is inaccessible when TS emits a large inferred aggregate -// type that transitively embeds it (e.g. preload's -// `export type WindowApiType = typeof api`, via `FileEntry.externalPath`), -// triggering TS2527/TS4023. A literal-keyed brand is fully nameable across -// modules, so it dodges that while keeping the nominal identity — a plain -// `string` still lacks the property, so the value can only be produced by the -// canonicalization path or an explicit `as` cast, exactly as documented above. -export type CanonicalExternalPath = string & { readonly __brand: 'CanonicalExternalPath' } - -/** - * Intersection brand carried by the `externalPath` field on the FileEntry - * BO: a string that is both **canonical** (provenance: passed through - * `canonicalizeAbsolutePath` / `canonicalizeExternalPath`) and **satisfies - * the `FilePath` template-literal shape** (so it can flow into any - * `@main/utils/file/*` API without a cast). - * - * Round 2 S5: the schema's `externalPath` field used to be plain - * `AbsolutePathSchema` (inferred as `string`), forcing five production - * sites to `as FilePath`-cast at every read. The schema now `refine`s - * against `canonicalizeAbsolutePath` (real runtime check; rejects any - * non-canonical input at parse time) and then `transform`s the result - * into this intersection — so consumers reading `entry.externalPath` - * get a value typed exactly as they need it, with the canonical - * provenance proven at the schema boundary. - */ -export type CanonicalFilePath = FilePath & CanonicalExternalPath - // ─── FileEntry Schema (discriminated union on origin, branded) ─── // // ## DB row vs Business Object @@ -292,29 +209,13 @@ export const ExternalEntrySchema = z.strictObject({ ...CommonEntryFields, origin: z.literal('external'), /** - * Absolute filesystem path to the user-provided file. The schema runs a - * **real** `canonicalize` equivalence check (not just a shape match): the - * input must equal `canonicalizeAbsolutePath(input)`, otherwise parse - * rejects. Combined with the `.transform` below, this means any value the - * BO ever exposes is provably canonical AND carries the `FilePath` shape, - * eliminating the five `as FilePath` casts that used to sit at every read - * site (rename.ts, lifecycle.ts, danglingCache.ts, …). + * Absolute filesystem path to the user-provided file. `FilePathSchema` + * transforms the value through `canonicalizeAbsolutePath` (NFC + resolve + + * trailing-strip + null-byte reject) and brands it `FilePath`, so any value + * the BO exposes is provably canonical — no `as FilePath` casts at read + * sites (rename.ts, lifecycle.ts, danglingCache.ts, …). */ - externalPath: AbsolutePathSchema.refine((s) => { - // canonicalizeAbsolutePath throws on structural failures (non-absolute, - // contains \0) — both already surfaced by `AbsolutePathSchema`'s own - // refines, but Zod does not short-circuit on prior refine failure, so we - // must absorb the throw here. Failure → return false → schema rejects - // with the canonicalization message (and the prior issue is also - // reported, giving the caller the full picture). - try { - return s === canonicalizeAbsolutePath(s) - } catch { - return false - } - }, 'externalPath must be canonicalized via canonicalizeExternalPath() before persistence').transform( - (s): CanonicalFilePath => s as CanonicalFilePath - ) + externalPath: FilePathSchema }) /** diff --git a/src/shared/data/types/file/index.ts b/src/shared/data/types/file/index.ts index fae1780214a..5d61915ba8a 100644 --- a/src/shared/data/types/file/index.ts +++ b/src/shared/data/types/file/index.ts @@ -9,4 +9,4 @@ export * from './ref' // `./legacyFileMetadata.ts` and is intentionally NOT re-exported — v2 code // uses `FileEntry`; the migration path imports `FileMetadata` from the legacy // module directly. -export { FILE_TYPE, type FileType, FileTypeSchema } from '@shared/types/file' +export { FILE_TYPE, FilePathSchema, type FileType, FileTypeSchema } from '@shared/types/file' diff --git a/src/shared/data/types/fileProcessing.ts b/src/shared/data/types/fileProcessing.ts index 3aea2d2f85c..495b2b55c00 100644 --- a/src/shared/data/types/fileProcessing.ts +++ b/src/shared/data/types/fileProcessing.ts @@ -1,7 +1,7 @@ import * as z from 'zod' import { FILE_PROCESSOR_IDS } from '../preference/preferenceTypes' -import { AbsolutePathSchema } from './file' +import { FilePathSchema } from './file' export const FileProcessingTextArtifactSchema = z .object({ @@ -15,7 +15,7 @@ export const FileProcessingFileArtifactSchema = z .object({ kind: z.literal('file'), format: z.literal('markdown'), - path: AbsolutePathSchema + path: FilePathSchema }) .strict() @@ -25,7 +25,7 @@ export const FileProcessingArtifactSchema = z.discriminatedUnion('kind', [ ]) export type FileProcessingArtifact = z.infer -export const FileProcessingOutputTargetSchema = z.object({ kind: z.literal('path'), path: AbsolutePathSchema }).strict() +export const FileProcessingOutputTargetSchema = z.object({ kind: z.literal('path'), path: FilePathSchema }).strict() export type FileProcessingOutputTarget = z.infer export const FileProcessingJobOutputSchema = z diff --git a/src/shared/data/types/knowledge.ts b/src/shared/data/types/knowledge.ts index 9dab17bd6fd..0f2bd068b2e 100644 --- a/src/shared/data/types/knowledge.ts +++ b/src/shared/data/types/knowledge.ts @@ -1,6 +1,6 @@ import * as z from 'zod' -import { AbsolutePathSchema } from './file' +import { FilePathSchema } from './file' import { GroupIdSchema } from './group' /** @@ -675,11 +675,11 @@ export const CreateKnowledgeItemSchema = z.discriminatedUnion('type', [ export type CreateKnowledgeItemDto = z.infer const RuntimeFileItemDataSchema = KnowledgeItemSharedSchema.extend({ - path: AbsolutePathSchema.describe('Absolute source path selected by the user before Knowledge copies it.'), + path: FilePathSchema.describe('Absolute source path selected by the user before Knowledge copies it.'), // Restore-only: absolute path to an already-produced processor artifact (e.g. MinerU // Markdown) in the source base. When present, Knowledge copies it in alongside the // source file and indexes from it directly, skipping the file processor. - indexedPath: AbsolutePathSchema.optional().describe( + indexedPath: FilePathSchema.optional().describe( 'Absolute path to an already-processed artifact to copy in and index from, skipping the file processor.' ) }) @@ -690,7 +690,7 @@ const RuntimeUrlItemDataSchema = KnowledgeItemSharedSchema.extend({ // When present, Knowledge copies it in and pins the item to it so the first index // reads the snapshot offline instead of re-fetching the (possibly changed or dead) // live page. Omitted by a normal add, which captures lazily on first index. - snapshotPath: AbsolutePathSchema.optional().describe( + snapshotPath: FilePathSchema.optional().describe( 'Absolute path to a captured URL snapshot markdown to copy in, skipping the live re-fetch.' ) }) diff --git a/src/shared/ipc/schemas/file.ts b/src/shared/ipc/schemas/file.ts index d9559c20c74..e78767d4479 100644 --- a/src/shared/ipc/schemas/file.ts +++ b/src/shared/ipc/schemas/file.ts @@ -1,8 +1,8 @@ import { - AbsolutePathSchema, DanglingStateSchema, FileEntryIdSchema, FileEntrySchema, + FilePathSchema, SafeNameSchema } from '@shared/data/types/file' import { FileHandleSchema, PhysicalFileMetadataSchema, SafeExtSchema } from '@shared/types/file' @@ -41,7 +41,7 @@ const batchCreateResultSchema = z.strictObject({ // future drift; refactor them to share one source of truth before migrating the // remaining File IPC surface. const createInternalEntryInputSchema = z.discriminatedUnion('source', [ - z.strictObject({ source: z.literal('path'), path: AbsolutePathSchema }), + z.strictObject({ source: z.literal('path'), path: FilePathSchema }), z.strictObject({ source: z.literal('url'), url: z.url() }), z.strictObject({ source: z.literal('base64'), data: z.string().min(1), name: SafeNameSchema.optional() }), z.strictObject({ @@ -69,7 +69,7 @@ export const fileRequestSchemas = { }), 'file.batch_get_physical_paths': defineRoute({ input: fileEntryIdsInputSchema, - output: z.record(z.string(), AbsolutePathSchema.nullable()) + output: z.record(z.string(), FilePathSchema.nullable()) }), 'file.batch_get_dangling_states': defineRoute({ input: fileEntryIdsInputSchema, diff --git a/src/shared/types/file/common.ts b/src/shared/types/file/common.ts index 505c5e2bd61..b6e037dde25 100644 --- a/src/shared/types/file/common.ts +++ b/src/shared/types/file/common.ts @@ -50,14 +50,7 @@ export const FilePathSchema = z .transform((v) => canonicalizeAbsolutePath(v)) .brand<'FilePath'>() -/** - * Local filesystem path (absolute Unix or Windows). - * - * Runtime validation required — the template-literal pattern only provides - * type-level hints. Rejects `file://` URLs; use a dedicated URL type (or plain - * `string`) when a consumer needs to accept URLs. - */ -export type FilePath = `/${string}` | `${string}:\\${string}` +export type FilePath = z.infer export type Base64String = `data:${string};base64,${string}` export type UrlString = `http://${string}` | `https://${string}` diff --git a/src/shared/types/file/handle.ts b/src/shared/types/file/handle.ts index bf8933eca6d..2e498262b07 100644 --- a/src/shared/types/file/handle.ts +++ b/src/shared/types/file/handle.ts @@ -22,10 +22,10 @@ * the handle shapes and their IPC-boundary schemas. */ -import { AbsolutePathSchema, type FileEntryId, FileEntryIdSchema } from '@shared/data/types/file' +import { type FileEntryId, FileEntryIdSchema } from '@shared/data/types/file' import * as z from 'zod' -import type { FilePath } from './common' +import { type FilePath, FilePathSchema } from './common' export type FileEntryHandle = { readonly kind: 'entry' @@ -52,9 +52,8 @@ export const FileEntryHandleSchema = z.strictObject({ export const FilePathHandleSchema = z.strictObject({ kind: z.literal('path'), - path: AbsolutePathSchema + path: FilePathSchema }) export const FileHandleSchema = z.discriminatedUnion('kind', [FileEntryHandleSchema, FilePathHandleSchema]) -// TODO: 1. Wire schema and types, so no as cast needed // TODO: 2. Add brand for FileHandle since factory function has been used diff --git a/src/shared/types/file/info.ts b/src/shared/types/file/info.ts index 778cb38c321..a013339df45 100644 --- a/src/shared/types/file/info.ts +++ b/src/shared/types/file/info.ts @@ -58,8 +58,7 @@ import * as z from 'zod' -import type { FilePath } from './common' -import { FileTypeSchema } from './common' +import { FilePathSchema, FileTypeSchema } from './common' /** * Zod schema for `FileInfo`. Branded so consumers cannot construct a raw @@ -74,17 +73,10 @@ import { FileTypeSchema } from './common' export const FileInfoSchema = z .strictObject({ /** - * Absolute filesystem path. The TypeScript template-literal `FilePath` - * brand is enforced only at the type level; the runtime check here is - * the same shape gate (`/`-prefixed POSIX or `X:\` Windows drive) - * plus a null-byte rejection — anything that survives is safe to feed - * to `fs` APIs. + * Absolute filesystem path. Canonicalized and branded `FilePath` by + * `FilePathSchema` — anything that survives is safe to feed to `fs` APIs. */ - path: z - .string() - .min(1) - .refine((s) => !s.includes('\0'), 'path must not contain null bytes') - .refine((s) => s.startsWith('/') || /^[A-Za-z]:\\/.test(s), 'path must be an absolute filesystem path'), + path: FilePathSchema, /** Basename without extension. */ name: z.string(), /** Extension without leading dot, or `null` for extensionless files. */ @@ -109,10 +101,8 @@ export const FileInfoSchema = z * @see {@link PhysicalFileMetadata} for per-kind rich stat (dimensions, * pageCount, etc.). * - * Inferred from `FileInfoSchema`; the schema is the source of truth. - * The runtime `path` shape check is intentionally weaker than the TS - * `FilePath` template literal (template literals can't be expressed in - * Zod) — the field is still typed as `FilePath` here for ergonomics - * everywhere it crosses an IPC boundary. + * Inferred from `FileInfoSchema`; the schema is the source of truth. `path` + * already carries the `FilePath` brand via `FilePathSchema`, so no `Omit` + * hack is needed to retype it. */ -export type FileInfo = Omit, 'path'> & { readonly path: FilePath } +export type FileInfo = z.infer diff --git a/src/shared/types/file/ipc.ts b/src/shared/types/file/ipc.ts index a77c64bfa1e..2c5abd51960 100644 --- a/src/shared/types/file/ipc.ts +++ b/src/shared/types/file/ipc.ts @@ -119,43 +119,21 @@ export type CreateInternalEntryIpcParams = * `getMetadata`. External entries cannot be trashed, so no "restore" branch * is possible. * - * ## Canonicalization stays on the main side (by design) + * ## Canonicalization * - * `externalPath` is intentionally typed as raw `FilePath` rather than - * `CanonicalExternalPath`. The asymmetry is deliberate: + * `externalPath` is typed as `FilePath`, which can only be produced by + * `FilePathSchema.parse()` / `.safeParse()` — the schema's `.transform` + * (NFC normalize, resolve segments, strip trailing separator, reject null + * bytes) runs in pure shared JS, so both renderer and main can construct one + * directly; production code MUST NEVER `as`-cast a raw `string` into `FilePath`. * - * - **Renderer has no canonicalize use case.** It never compares paths - * for dedup (the DB-level `UNIQUE(externalPath)` index does that - * after `ensureExternalEntry`), never derives a canonical projection, - * and never uses paths as join keys. Every path the renderer holds - * either flows back to main (for an IPC call) or feeds a system API - * that itself accepts arbitrary user paths. - * - **Canonicalization implementation is main-only.** - * `canonicalizeExternalPath` (`src/main/services/file/utils/pathResolver.ts`) - * depends on main-only modules (realpath / NFC / case-fold). Asking the - * renderer to canonicalize would either duplicate that logic or - * require an extra IPC hop per call — no upside for either choice. - * - **The brand is already protected by a project rule, not by JSDoc.** - * `fileEntry.ts` makes the construction discipline explicit: only the - * `canonicalizeExternalPath` factory may produce `CanonicalExternalPath`; - * production code MUST NEVER `as`-cast into the brand. Code that - * bypasses the gate violates the rule, not just an inline comment — - * PR review catches it the same way it catches any other rule break. - * - * **Why not extend the brand to the IPC boundary** (e.g. a `RawExternalPath` - * brand on the param): the renderer would have to `as`-cast `string → - * RawExternalPath` at the call site, which is itself a violation of the - * same "no production `as`-cast into brands" rule. The proposal therefore - * trades one boundary's discipline for another's, without adding actual - * enforcement; meanwhile dev / test ergonomics get worse at every call - * site. The four current main consumers (FileManager.ensureExternalEntry, - * FileManager.rename, `internal/entry/rename.ts`, `internal/entry/create.ts`) - * already canonicalize before any DB lookup, and Phase 2 consumers join - * that pattern via code review at the same sites. - * - * Skipping canonicalization silently misses entries on case-insensitive - * filesystems and after symlink resolution — which is why the gate exists - * at all. + * What stays main-only is the **disambiguation** step beyond schema-level + * canonicalization: `ensureExternalEntry` additionally runs `fs.realpath` to + * resolve case-insensitive-filesystem collisions (see + * `src/main/services/file/utils/pathResolver.ts` and + * `internal/entry/create.ts`), which depends on main-only FS APIs. Skipping + * that disambiguation silently misses entries on case-insensitive filesystems + * and after symlink resolution — which is why it stays a main-side concern. */ export type EnsureExternalEntryIpcParams = { externalPath: FilePath diff --git a/src/shared/utils/file/__tests__/handle.test.ts b/src/shared/utils/file/__tests__/handle.test.ts index 49c9524c955..f0d96d90751 100644 --- a/src/shared/utils/file/__tests__/handle.test.ts +++ b/src/shared/utils/file/__tests__/handle.test.ts @@ -12,7 +12,7 @@ describe('createFileEntryHandle', () => { describe('createFilePathHandle — runtime validation', () => { it('accepts POSIX absolute paths', () => { - const h = createFilePathHandle('/Users/me/doc.pdf') + const h = createFilePathHandle('/Users/me/doc.pdf' as FilePath) expect(h).toEqual({ kind: 'path', path: '/Users/me/doc.pdf' }) }) @@ -53,7 +53,7 @@ describe('handle type guards', () => { }) it('isFilePathHandle narrows to the path variant', () => { - const h = createFilePathHandle('/tmp/x') + const h = createFilePathHandle('/tmp/x' as FilePath) expect(isFilePathHandle(h)).toBe(true) expect(isFileEntryHandle(h)).toBe(false) }) diff --git a/src/shared/utils/file/canonicalize.ts b/src/shared/utils/file/canonicalize.ts index 78c17fd2c7d..4549eb416be 100644 --- a/src/shared/utils/file/canonicalize.ts +++ b/src/shared/utils/file/canonicalize.ts @@ -1,18 +1,14 @@ /** * Pure-JS canonicalization for absolute filesystem paths. * - * Lives in shared (no `node:*` imports) so the FileEntry schema can `refine` - * its `externalPath` field against the same canonicalization rule the main - * process uses on write. That refine is what gives the `CanonicalFilePath` - * brand on the BO real runtime backing — any value that survives parsing IS - * canonical, not just typed as if it were. + * Lives in shared (no `node:*` imports) so `FilePathSchema` (`@shared/types/file/common`) + * can `.transform` its input through the same canonicalization rule on every + * parse. That transform is what gives the `FilePath` brand real runtime + * backing — any value that survives parsing IS canonical, not just typed as + * if it were. * * ## Scope (this function's contract) * - * Same rules as the main-side `canonicalizeExternalPath` (see - * `src/main/services/file/utils/pathResolver.ts`) — only the implementation - * differs (this version does not depend on `node:path`): - * * 0. Reject null bytes (`\0`). * 1. Resolve segments (`.`, `..`, repeated separators). * 2. Unicode NFC normalize. From 738c6d33fbd2a7ed68fdf9c65025e2cf54af06ff Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sat, 4 Jul 2026 16:33:46 +0800 Subject: [PATCH 03/22] refactor(file): add filepath-brand/no-as-filepath lint guard Forbid `as FilePath` in production code (CI error / local warn); exempt tests and the raw-OS-path watcher/tree regimes. Convert the canonicalizable production casts to FilePathSchema.parse() and drop the ones made redundant by the branded IPC output schemas. Fix a latent inconsistency in url.ts's dirnameSimple surfaced by the new validation: the Windows-drive-root case dropped its trailing separator (`C:\payload.exe` -> `C:`), which FilePathSchema rejects as non-absolute; the POSIX-root case already preserved it (`/payload.exe` -> `/`). Preserve it for Windows too. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- eslint.config.mjs | 54 +++++++++++++++++++ src/main/ai/messages/fileProcessor.ts | 4 +- .../v2/migrators/KnowledgeMigrator.ts | 6 +-- .../v2/migrators/mappings/ChatMappings.ts | 4 +- .../knowledge/utils/storage/pathStorage.ts | 20 +++---- .../services/file/internal/system/tempCopy.ts | 6 +-- src/main/utils/file/pathStatus.ts | 4 +- .../useMessageLeafCapabilities.test.tsx | 22 ++++++++ .../hooks/useMessageLeafCapabilities.ts | 8 +-- .../components/chat/panes/ArtifactPane.tsx | 4 +- .../chat/panes/useArtifactFileTreeModel.ts | 6 +-- .../tokenView/fileTokenPresentation.tsx | 6 ++- src/renderer/hooks/useFileSize.ts | 4 +- .../hooks/usePaintingComposerInputFiles.ts | 7 ++- .../utils/__tests__/paintingFileUrl.test.ts | 5 ++ .../pages/paintings/utils/paintingFileUrl.ts | 6 ++- .../pages/translate/TranslatePage.tsx | 4 +- src/renderer/utils/file/buildFileParts.ts | 7 ++- src/shared/utils/file/__tests__/url.test.ts | 2 +- src/shared/utils/file/url.ts | 15 ++++-- 20 files changed, 148 insertions(+), 46 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 4fb3676c657..98562778d95 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -212,6 +212,60 @@ export default defineConfig([ ] } }, + // FilePath brand integrity — `as FilePath` forges the brand, skipping + // FilePathSchema's canonicalization (NFC normalize + segment resolution). + // Production code must build a FilePath via FilePathSchema.parse(value). + // Exemptions: test fixtures; and two deliberate raw-OS-path regimes — the + // directory watcher emits the raw path by design, and the tree builder + // compares rootPath against raw absPaths — where canonicalizing would + // reintroduce the NFC divergence this brand exists to prevent. + { + files: ['src/**/*.{ts,tsx}'], + ignores: [ + 'src/main/services/file/watcher/**', + 'src/main/services/file/tree/**', + 'src/**/__tests__/**', + 'src/**/__mocks__/**', + 'src/**/*.test.*' + ], + plugins: { + 'filepath-brand': { + rules: { + 'no-as-filepath': { + meta: { + type: 'problem', + docs: { + description: + 'Disallow `as FilePath` casts. FilePath is a Zod brand asserting a canonicalized path; forging it bypasses the canonicalization guarantee. Construct via FilePathSchema.parse().', + recommended: true + }, + messages: { + noAsFilePath: + '`as FilePath` forges the brand and skips canonicalization. Build it with FilePathSchema.parse(value) instead. If this is a deliberate raw-path regime, move it under an exempted path or justify with an eslint-disable + reason.' + } + }, + create(context) { + return { + TSAsExpression(node) { + const ann = node.typeAnnotation + if ( + ann?.type === 'TSTypeReference' && + ann.typeName?.type === 'Identifier' && + ann.typeName.name === 'FilePath' + ) { + context.report({ node, messageId: 'noAsFilePath' }) + } + } + } + } + } + } + } + }, + rules: { + 'filepath-brand/no-as-filepath': process.env.CI ? 'error' : 'warn' + } + }, // Application lifecycle - all quit-related APIs and events are managed by Application.ts { files: ['src/main/**/*.{ts,tsx,js,jsx}'], diff --git a/src/main/ai/messages/fileProcessor.ts b/src/main/ai/messages/fileProcessor.ts index d929e6bf2c3..dcaf6131701 100644 --- a/src/main/ai/messages/fileProcessor.ts +++ b/src/main/ai/messages/fileProcessor.ts @@ -22,7 +22,7 @@ import { loggerService } from '@logger' import { read as fsRead } from '@main/utils/file/fs' import type { FileUIPart } from '@shared/data/types/message' import { readCherryMeta } from '@shared/data/types/uiParts' -import type { FilePath } from '@shared/types/file' +import { FilePathSchema } from '@shared/types/file' const logger = loggerService.withContext('ai:fileProcessor') @@ -51,7 +51,7 @@ async function fileEntryIdToDataUrl(fileEntryId: string) { */ async function fileUrlToDataUrl(fileUrl: string) { try { - const absPath = fileURLToPath(fileUrl) as FilePath + const absPath = FilePathSchema.parse(fileURLToPath(fileUrl)) const { data, mime } = await fsRead(absPath, { encoding: 'base64' }) return { url: `data:${mime};base64,${data}`, mediaType: mime } } catch (error) { diff --git a/src/main/data/migration/v2/migrators/KnowledgeMigrator.ts b/src/main/data/migration/v2/migrators/KnowledgeMigrator.ts index 1f763f012f3..9c5e19a314a 100644 --- a/src/main/data/migration/v2/migrators/KnowledgeMigrator.ts +++ b/src/main/data/migration/v2/migrators/KnowledgeMigrator.ts @@ -22,7 +22,7 @@ import { KNOWLEDGE_BASE_ERROR_MISSING_VECTOR_STORE } from '@shared/data/types/knowledge' import { UNIQUE_MODEL_ID_SEPARATOR, type UniqueModelId } from '@shared/data/types/model' -import type { FilePath } from '@shared/types/file' +import { FilePathSchema } from '@shared/types/file' import Database from 'better-sqlite3' import { eq, sql } from 'drizzle-orm' @@ -1054,8 +1054,8 @@ export class KnowledgeMigrator extends BaseMigrator { const destPath = path.join(ctx.paths.knowledgeBaseDir, baseId, 'raw', relativePath) try { - await ensureDir(path.dirname(destPath) as FilePath) - await copy(sourcePath as FilePath, destPath as FilePath) + await ensureDir(FilePathSchema.parse(path.dirname(destPath))) + await copy(FilePathSchema.parse(sourcePath), FilePathSchema.parse(destPath)) } catch (error) { this.recordWarning( `Failed to copy knowledge file for item ${item.id} (${sourcePath} → ${destPath}): ${ diff --git a/src/main/data/migration/v2/migrators/mappings/ChatMappings.ts b/src/main/data/migration/v2/migrators/mappings/ChatMappings.ts index eee62bb4b2f..c2b2d03f1d6 100644 --- a/src/main/data/migration/v2/migrators/mappings/ChatMappings.ts +++ b/src/main/data/migration/v2/migrators/mappings/ChatMappings.ts @@ -64,7 +64,7 @@ import type { } from '@shared/data/types/message' import type { CherryDataPartTypes, CherryToolMeta } from '@shared/data/types/uiParts' import { withCherryMeta } from '@shared/data/types/uiParts' -import type { Base64String, FilePath } from '@shared/types/file/common' +import { type Base64String, FilePathSchema } from '@shared/types/file/common' import type { SourceUrlUIPart } from 'ai' import mime from 'mime' import { v7 as uuidv7 } from 'uuid' @@ -953,7 +953,7 @@ async function promoteBase64ToFileEntry( const ext = mime.getExtension(mimeType) const id = uuidv7() const filename = ext ? `${id}.${ext}` : id - const physicalPath = path.join(filesDataDir, filename) as FilePath + const physicalPath = FilePathSchema.parse(path.join(filesDataDir, filename)) const bytes = Buffer.from(payload, 'base64') let physicalWritten = false diff --git a/src/main/features/knowledge/utils/storage/pathStorage.ts b/src/main/features/knowledge/utils/storage/pathStorage.ts index 8319395e183..753b3d3c9f8 100644 --- a/src/main/features/knowledge/utils/storage/pathStorage.ts +++ b/src/main/features/knowledge/utils/storage/pathStorage.ts @@ -6,7 +6,7 @@ import { loggerService } from '@logger' import { getFileExt } from '@main/utils/file' import { copy, ensureDir, type PathReadability, probeReadable, remove, removeDir, write } from '@main/utils/file/fs' import { nextFreeKnowledgeRelativePath } from '@main/utils/knowledge' -import type { FilePath } from '@shared/types/file' +import { type FilePath, FilePathSchema } from '@shared/types/file' import { knowledgeFileProcessingExts } from '@shared/utils/file' const logger = loggerService.withContext('Knowledge:PathStorage') @@ -31,16 +31,16 @@ const VECTOR_STORE_FILE = 'index.sqlite' const MATERIAL_ROOT_DIR = 'raw' export function getKnowledgeBaseDir(baseId: string): FilePath { - return path.join(application.getPath('feature.knowledgebase.data'), baseId) as FilePath + return FilePathSchema.parse(path.join(application.getPath('feature.knowledgebase.data'), baseId)) } /** The material root (`{baseDir}/raw`) under which every `relativePath` resolves. */ export function getKnowledgeMaterialDir(baseId: string): FilePath { - return path.join(getKnowledgeBaseDir(baseId), MATERIAL_ROOT_DIR) as FilePath + return FilePathSchema.parse(path.join(getKnowledgeBaseDir(baseId), MATERIAL_ROOT_DIR)) } export function getKnowledgeBaseMetaDir(baseId: string): FilePath { - return path.join(getKnowledgeBaseDir(baseId), CHERRY_META_DIR) as FilePath + return FilePathSchema.parse(path.join(getKnowledgeBaseDir(baseId), CHERRY_META_DIR)) } /** @@ -51,12 +51,12 @@ export function getKnowledgeBaseMetaDir(baseId: string): FilePath { */ export function getKnowledgeVectorStoreFilePathSync(baseId: string): FilePath { const metaDir = getKnowledgeBaseMetaDir(baseId) - return path.join(metaDir, VECTOR_STORE_FILE) as FilePath + return FilePathSchema.parse(path.join(metaDir, VECTOR_STORE_FILE)) } export function getKnowledgeBaseFilePath(baseId: string, relativePath: string): FilePath { assertSafeKnowledgeRelativePath(relativePath) - return path.join(getKnowledgeMaterialDir(baseId), relativePath) as FilePath + return FilePathSchema.parse(path.join(getKnowledgeMaterialDir(baseId), relativePath)) } /** @@ -75,7 +75,7 @@ export async function probeKnowledgeFile(baseId: string, relativePath: string): * is already absolute, so it is probed as-is. */ export async function probeKnowledgeSourcePath(absolutePath: string): Promise { - return probeReadable(absolutePath as FilePath) + return probeReadable(FilePathSchema.parse(absolutePath)) } export function getKnowledgeSourceRelativePath(sourcePath: string): string { @@ -135,8 +135,8 @@ export async function copyFileIntoKnowledgeBaseAt( ): Promise { const destPath = getKnowledgeBaseFilePath(baseId, relativePath) await assertTargetAvailable(destPath) - await ensureDir(path.dirname(destPath) as FilePath) - await copy(sourcePath as FilePath, destPath) + await ensureDir(FilePathSchema.parse(path.dirname(destPath))) + await copy(FilePathSchema.parse(sourcePath), destPath) return relativePath } @@ -148,7 +148,7 @@ export async function writeFileIntoKnowledgeBaseAt( ): Promise { const destPath = getKnowledgeBaseFilePath(baseId, relativePath) await assertTargetAvailable(destPath) - await ensureDir(path.dirname(destPath) as FilePath) + await ensureDir(FilePathSchema.parse(path.dirname(destPath))) await write(destPath, content) return relativePath } diff --git a/src/main/services/file/internal/system/tempCopy.ts b/src/main/services/file/internal/system/tempCopy.ts index 844f9e61b3e..ef7e1003530 100644 --- a/src/main/services/file/internal/system/tempCopy.ts +++ b/src/main/services/file/internal/system/tempCopy.ts @@ -15,7 +15,7 @@ import { application } from '@application' import { loggerService } from '@logger' import { copy as fsCopy, removeDir as fsRemoveDir } from '@main/utils/file/fs' import type { FileEntryId } from '@shared/data/types/file' -import type { FilePath } from '@shared/types/file' +import { FilePathSchema } from '@shared/types/file' import { resolvePhysicalPath } from '../../utils/pathResolver' import type { FileManagerDeps } from '../deps' @@ -35,7 +35,7 @@ export async function withTempCopy( const parent = application.getPath('feature.files.tempcopy.temp') const dir = await mkdtemp(path.join(parent, 'tc-')) const filename = `${entry.name}${entry.ext ? `.${entry.ext}` : ''}` || 'file' - const target = path.join(dir, filename) as FilePath + const target = FilePathSchema.parse(path.join(dir, filename)) try { await fsCopy(physical, target) return await fn(target) @@ -50,7 +50,7 @@ export async function withTempCopy( // on the next OS-level temp cleanup. No application-side sweeper is // planned. try { - await fsRemoveDir(dir as FilePath) + await fsRemoveDir(FilePathSchema.parse(dir)) } catch (cleanupErr) { logger.warn('withTempCopy: temp dir cleanup failed; directory will leak until OS temp reap', { dir, diff --git a/src/main/utils/file/pathStatus.ts b/src/main/utils/file/pathStatus.ts index 77d0d3c05a2..4cb98bbb13b 100644 --- a/src/main/utils/file/pathStatus.ts +++ b/src/main/utils/file/pathStatus.ts @@ -1,4 +1,4 @@ -import type { FilePath } from '@shared/types/file' +import { FilePathSchema } from '@shared/types/file' import { stat } from './fs' @@ -27,7 +27,7 @@ export async function getPathStatus(path: string): Promise { } try { - const stats = await stat(path as FilePath) + const stats = await stat(FilePathSchema.parse(path)) return { ok: true, kind: stats.isDirectory ? 'directory' : 'file' } } catch (error) { // `ENOENT` (nothing there) and `ENOTDIR` (a path component is a diff --git a/src/renderer/components/chat/messages/hooks/__tests__/useMessageLeafCapabilities.test.tsx b/src/renderer/components/chat/messages/hooks/__tests__/useMessageLeafCapabilities.test.tsx index 7294d81e904..c07860e78dc 100644 --- a/src/renderer/components/chat/messages/hooks/__tests__/useMessageLeafCapabilities.test.tsx +++ b/src/renderer/components/chat/messages/hooks/__tests__/useMessageLeafCapabilities.test.tsx @@ -180,6 +180,28 @@ describe('useMessageLeafCapabilities', () => { }) }) + it('omits the preview url without throwing when the shared attachment path is not absolute', () => { + const { result } = renderHook(() => useMessageLeafCapabilities({ partsByMessageId: {} })) + + const file: FileMetadata = { + id: 'file-1', + type: FILE_TYPE.DOCUMENT, + ext: '.pdf', + path: 'relative/legacy.pdf', + origin_name: 'file.pdf', + name: 'stored-file.pdf', + size: 100, + created_at: '2026-01-01T00:00:00.000Z', + count: 1 + } + + expect(() => result.current.getFileView?.(file)).not.toThrow() + expect(result.current.getFileView?.(file)).toEqual({ + displayName: 'file.pdf', + previewUrl: undefined + }) + }) + it('keeps legacy pasted temp-file display behavior local to message attachments', () => { const { result } = renderHook(() => useMessageLeafCapabilities({ partsByMessageId: {} })) diff --git a/src/renderer/components/chat/messages/hooks/useMessageLeafCapabilities.ts b/src/renderer/components/chat/messages/hooks/useMessageLeafCapabilities.ts index 2736ec11199..3749585c182 100644 --- a/src/renderer/components/chat/messages/hooks/useMessageLeafCapabilities.ts +++ b/src/renderer/components/chat/messages/hooks/useMessageLeafCapabilities.ts @@ -9,7 +9,8 @@ import { parseFileTypes } from '@renderer/utils/file' import { safeOpen } from '@renderer/utils/file/safeOpen' import type { CherryMessagePart } from '@shared/data/types/message' import { IpcChannel } from '@shared/IpcChannel' -import type { FileHandle, FilePath } from '@shared/types/file' +import type { FileHandle } from '@shared/types/file' +import { FilePathSchema } from '@shared/types/file' import type { McpProgressEvent } from '@shared/types/mcp' import { createFileEntryHandle, createFilePathHandle, toSafeFileUrl } from '@shared/utils/file' import dayjs from 'dayjs' @@ -53,7 +54,7 @@ function isMcpToolPart(part: CherryMessagePart): boolean { function fileMetadataToHandle(file: FileMetadata): FileHandle { if (file.path) { try { - return createFilePathHandle(file.path as FilePath) + return createFilePathHandle(FilePathSchema.parse(file.path)) } catch { // Fall back to the entry id for legacy FileMetadata whose path is not an // absolute filesystem path. The IPC schema is still the authority. @@ -134,9 +135,10 @@ export function useMessageLeafCapabilities({ const getFileView = useCallback>( (file) => { + const parsedPath = file.path ? FilePathSchema.safeParse(file.path) : undefined return { displayName: formatMessageAttachmentFileName(file, t), - previewUrl: file.path ? toSafeFileUrl(file.path as FilePath, file.ext || null) : undefined + previewUrl: parsedPath?.success ? toSafeFileUrl(parsedPath.data, file.ext || null) : undefined } }, [t] diff --git a/src/renderer/components/chat/panes/ArtifactPane.tsx b/src/renderer/components/chat/panes/ArtifactPane.tsx index a8dd1056f8e..d6182289cac 100644 --- a/src/renderer/components/chat/panes/ArtifactPane.tsx +++ b/src/renderer/components/chat/panes/ArtifactPane.tsx @@ -12,7 +12,7 @@ import { type IsTextState, useIsTextFile } from '@renderer/hooks/useIsTextFile' import { useResizeDrag } from '@renderer/hooks/useResizeDrag' import { getLanguageByFilePath } from '@renderer/utils/codeLanguage' import { joinPath } from '@renderer/utils/path' -import type { FilePath } from '@shared/types/file/common' +import { FilePathSchema } from '@shared/types/file/common' import { toFileUrl } from '@shared/utils/file/url' import { AlertCircle, FileText, Folder, FolderOpen, Maximize2, Minimize2, RotateCw, Sparkles } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' @@ -450,7 +450,7 @@ export function ArtifactFilePreview({ key={`html-${filePath}-${contentRefreshKey}`} html={fileContent ?? ''} title={filePath} - baseUrl={toFileUrl(joinPath(workspacePath, filePath) as FilePath)} + baseUrl={toFileUrl(FilePathSchema.parse(joinPath(workspacePath, filePath)))} /> ) } diff --git a/src/renderer/components/chat/panes/useArtifactFileTreeModel.ts b/src/renderer/components/chat/panes/useArtifactFileTreeModel.ts index 6c035f101f2..350bc6c0aa9 100644 --- a/src/renderer/components/chat/panes/useArtifactFileTreeModel.ts +++ b/src/renderer/components/chat/panes/useArtifactFileTreeModel.ts @@ -2,7 +2,7 @@ import { loggerService } from '@logger' import { type FileTreeNode } from '@renderer/components/FileTree' import { useDirectoryTree } from '@renderer/hooks/useDirectoryTree' import { joinPath } from '@renderer/utils/path' -import type { FilePath } from '@shared/types/file/common' +import { FilePathSchema } from '@shared/types/file/common' import type { CreateTreeIpcResult, DirectoryTreeOptions, @@ -248,7 +248,7 @@ function useArtifactFileSearch(workspacePath: string | undefined, searchKeyword: const timeout = setTimeout(() => { void (async () => { try { - const entries = await window.api.file.listDirectoryEntries(workspacePath as FilePath, { + const entries = await window.api.file.listDirectoryEntries(FilePathSchema.parse(workspacePath), { recursive: true, maxDepth: 0, includeHidden: false, @@ -366,7 +366,7 @@ function useLazyArtifactFileTree({ try { // One round trip that classifies each entry — avoids an `isDirectory` // IPC call per entry (was N+1 round trips per expanded folder). - const entries = await window.api.file.listDirectoryEntries(dirPath as FilePath, { + const entries = await window.api.file.listDirectoryEntries(FilePathSchema.parse(dirPath), { recursive: false, includeHidden: false, includeFiles: true, diff --git a/src/renderer/components/composer/tokenView/fileTokenPresentation.tsx b/src/renderer/components/composer/tokenView/fileTokenPresentation.tsx index 9e255cc9978..95f971ed812 100644 --- a/src/renderer/components/composer/tokenView/fileTokenPresentation.tsx +++ b/src/renderer/components/composer/tokenView/fileTokenPresentation.tsx @@ -1,6 +1,6 @@ import { FILE_TYPE } from '@renderer/types/file' import type { ComposerAttachment } from '@renderer/utils/message/composerAttachment' -import type { FilePath } from '@shared/types/file' +import { FilePathSchema } from '@shared/types/file' import { toSafeFileUrl } from '@shared/utils/file/url' import { File, FileCode2, FileImage, FileJson, FileSpreadsheet, FileText, FileType2, Presentation } from 'lucide-react' import type { ComponentType, ReactNode } from 'react' @@ -112,7 +112,9 @@ function getFileExtensionLabel(file: ComposerAttachment | undefined, fallbackLab function getFilePreviewUrl(file: ComposerAttachment | undefined) { if (!file?.path || file.type !== FILE_TYPE.IMAGE) return undefined - return toSafeFileUrl(file.path as FilePath, file.ext || null) + const parsedPath = FilePathSchema.safeParse(file.path) + if (!parsedPath.success) return undefined + return toSafeFileUrl(parsedPath.data, file.ext || null) } function getFileTokenVariant(file: ComposerAttachment | undefined, fallbackLabel: string): FileTokenVariant { diff --git a/src/renderer/hooks/useFileSize.ts b/src/renderer/hooks/useFileSize.ts index 2152857ac5c..4ac7818ea88 100644 --- a/src/renderer/hooks/useFileSize.ts +++ b/src/renderer/hooks/useFileSize.ts @@ -1,6 +1,6 @@ import { loggerService } from '@logger' import { joinPath } from '@renderer/utils/path' -import type { FilePath } from '@shared/types/file/common' +import { FilePathSchema } from '@shared/types/file/common' import { createFilePathHandle } from '@shared/utils/file/handle' import { useEffect, useState } from 'react' @@ -26,7 +26,7 @@ export function useFileSize( void (async () => { try { - const metadata = await window.api.file.getMetadata(createFilePathHandle(absPath as FilePath)) + const metadata = await window.api.file.getMetadata(createFilePathHandle(FilePathSchema.parse(absPath))) if (!cancelled) setState({ status: 'ok', size: metadata.size }) } catch (err) { if (cancelled) return diff --git a/src/renderer/pages/paintings/hooks/usePaintingComposerInputFiles.ts b/src/renderer/pages/paintings/hooks/usePaintingComposerInputFiles.ts index 1d2f43964fb..ee894bd970f 100644 --- a/src/renderer/pages/paintings/hooks/usePaintingComposerInputFiles.ts +++ b/src/renderer/pages/paintings/hooks/usePaintingComposerInputFiles.ts @@ -2,7 +2,7 @@ import { loggerService } from '@logger' import type { ComposerAttachment } from '@renderer/utils/message/composerAttachment' import { createComposerFileTokenSourceId } from '@renderer/utils/message/composerFileTokenSource' import type { FileEntry } from '@shared/data/types/file/fileEntry' -import type { FilePath } from '@shared/types/file/common' +import { FilePathSchema } from '@shared/types/file/common' import { getFileTypeByExt } from '@shared/utils/file/fileType' import { type Dispatch, type SetStateAction, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' @@ -120,7 +120,10 @@ export function usePaintingComposerInputFiles({ paintingId, inputFiles, files, s continue } try { - const entry = await window.api.file.createInternalEntry({ source: 'path', path: file.path as FilePath }) + const entry = await window.api.file.createInternalEntry({ + source: 'path', + path: FilePathSchema.parse(file.path) + }) cache.set(file.fileTokenSourceId, entry) entries.push(entry) } catch (error) { diff --git a/src/renderer/pages/paintings/utils/__tests__/paintingFileUrl.test.ts b/src/renderer/pages/paintings/utils/__tests__/paintingFileUrl.test.ts index 081475dd5fe..c076a9baca5 100644 --- a/src/renderer/pages/paintings/utils/__tests__/paintingFileUrl.test.ts +++ b/src/renderer/pages/paintings/utils/__tests__/paintingFileUrl.test.ts @@ -16,6 +16,11 @@ describe('getPaintingFileUrl', () => { ).toBe('file:///resolved%20output/paint%20result.png') }) + it('returns undefined without throwing when the path is not absolute', () => { + expect(() => getPaintingFileUrl({ path: 'relative/legacy.png', ext: '.png' })).not.toThrow() + expect(getPaintingFileUrl({ path: 'relative/legacy.png', ext: '.png' })).toBeUndefined() + }) + it('keeps shared file-url safety behavior', () => { expect( getPaintingFileUrl({ diff --git a/src/renderer/pages/paintings/utils/paintingFileUrl.ts b/src/renderer/pages/paintings/utils/paintingFileUrl.ts index 811ede79ac9..6910559538b 100644 --- a/src/renderer/pages/paintings/utils/paintingFileUrl.ts +++ b/src/renderer/pages/paintings/utils/paintingFileUrl.ts @@ -1,5 +1,5 @@ import type { FileMetadata } from '@renderer/types/file' -import type { FilePath, FileUrlString } from '@shared/types/file' +import { FilePathSchema, type FileUrlString } from '@shared/types/file' import { toSafeFileUrl } from '@shared/utils/file' type PaintingFileUrlSource = Pick @@ -11,5 +11,7 @@ type PaintingFileUrlSource = Pick */ export function getPaintingFileUrl(file: PaintingFileUrlSource): FileUrlString | undefined { if (!file.path) return undefined - return toSafeFileUrl(file.path as FilePath, file.ext || null) + const parsedPath = FilePathSchema.safeParse(file.path) + if (!parsedPath.success) return undefined + return toSafeFileUrl(parsedPath.data, file.ext || null) } diff --git a/src/renderer/pages/translate/TranslatePage.tsx b/src/renderer/pages/translate/TranslatePage.tsx index 2677cc2aa33..d5a35594b34 100644 --- a/src/renderer/pages/translate/TranslatePage.tsx +++ b/src/renderer/pages/translate/TranslatePage.tsx @@ -43,7 +43,7 @@ import { type UniqueModelId } from '@shared/data/types/model' import type { TranslateHistory } from '@shared/data/types/translate' -import type { FilePath } from '@shared/types/file' +import { FilePathSchema } from '@shared/types/file' import { MB } from '@shared/utils/constants' import { createFilePathHandle } from '@shared/utils/file' import { documentExts, imageExts, textExts } from '@shared/utils/file/fileExtensions' @@ -501,7 +501,7 @@ const TranslatePage: FC = () => { try { const snapshot = await ipcApi.request('file_processing.start_job', { feature: 'image_to_text', - file: createFilePathHandle(file.path as FilePath) + file: createFilePathHandle(FilePathSchema.parse(file.path)) }) jobId = snapshot.id } catch (error) { diff --git a/src/renderer/utils/file/buildFileParts.ts b/src/renderer/utils/file/buildFileParts.ts index ae45ac421fa..c286dc3de43 100644 --- a/src/renderer/utils/file/buildFileParts.ts +++ b/src/renderer/utils/file/buildFileParts.ts @@ -14,7 +14,7 @@ import type { ComposerAttachment } from '@renderer/utils/message/composerAttachment' import type { FileUIPart } from '@shared/data/types/message' import { withCherryMeta } from '@shared/data/types/uiParts' -import type { FilePath } from '@shared/types/file/common' +import { FilePathSchema } from '@shared/types/file' import { createFilePathHandle } from '@shared/utils/file/handle' /** @@ -26,7 +26,10 @@ import { createFilePathHandle } from '@shared/utils/file/handle' export async function buildFilePartsForAttachments(attachments: ComposerAttachment[]): Promise { return Promise.all( attachments.map(async (attachment) => { - const entry = await window.api.file.createInternalEntry({ source: 'path', path: attachment.path as FilePath }) + const entry = await window.api.file.createInternalEntry({ + source: 'path', + path: FilePathSchema.parse(attachment.path) + }) const physicalPath = await window.api.file.getPhysicalPath({ id: entry.id }) const metadata = await window.api.file.getMetadata(createFilePathHandle(physicalPath)) const basePart: FileUIPart = { diff --git a/src/shared/utils/file/__tests__/url.test.ts b/src/shared/utils/file/__tests__/url.test.ts index fe7d24cb4da..b2ca4f89a76 100644 --- a/src/shared/utils/file/__tests__/url.test.ts +++ b/src/shared/utils/file/__tests__/url.test.ts @@ -154,6 +154,6 @@ describe('toSafeFileUrl', () => { // renderer would end up with `file:///payload.exe`, which `` / // `` can hand to OS file associations. expect(toSafeFileUrl('/payload.exe' as FilePath, 'exe')).toBe('file:///') - expect(toSafeFileUrl('C:\\payload.exe' as FilePath, 'exe')).toBe('file:///C:') + expect(toSafeFileUrl('C:\\payload.exe' as FilePath, 'exe')).toBe('file:///C:/') }) }) diff --git a/src/shared/utils/file/url.ts b/src/shared/utils/file/url.ts index 7fd94b9826d..079dc042c12 100644 --- a/src/shared/utils/file/url.ts +++ b/src/shared/utils/file/url.ts @@ -33,7 +33,7 @@ * access, or main-process singletons belongs in File IPC. */ -import { type FilePath, type FileUrlString, SafeExtSchema } from '@shared/types/file/common' +import { type FilePath, FilePathSchema, type FileUrlString, SafeExtSchema } from '@shared/types/file/common' // ─── Danger extension policy ─── @@ -147,10 +147,19 @@ export function isDangerExt(ext: string | null | undefined): boolean { * `sepIdx === 0` is the POSIX-root case (`/payload.exe`): degrade to `'/'` so * the safety wrap in `toSafeFileUrl` still strips the filename. Returning the * original string here would defeat the entire danger-ext policy. + * + * The Windows-drive-root case (`C:\payload.exe`) needs the same treatment: + * slicing off just the separator would leave a bare `C:`, which + * `FilePathSchema` rejects as non-absolute (it requires the trailing + * separator to recognize a drive letter as a root). Keep the separator so + * the result stays a valid, canonical drive root (`C:\`). */ function dirnameSimple(absolutePath: string): string { const sepIdx = Math.max(absolutePath.lastIndexOf('/'), absolutePath.lastIndexOf('\\')) - if (sepIdx > 0) return absolutePath.slice(0, sepIdx) + if (sepIdx > 0) { + const dir = absolutePath.slice(0, sepIdx) + return /^[A-Za-z]:$/.test(dir) ? absolutePath.slice(0, sepIdx + 1) : dir + } if (sepIdx === 0) return '/' return absolutePath } @@ -224,5 +233,5 @@ export function fileUrlToPath(fileUrl: FileUrlString | URL): string { */ export function toSafeFileUrl(absolutePath: FilePath, ext: string | null): FileUrlString { const effectivePath = isDangerExt(ext) ? dirnameSimple(absolutePath) : absolutePath - return toFileUrl(effectivePath as FilePath) + return toFileUrl(FilePathSchema.parse(effectivePath)) } From 576f9c47b48c26205fc0a4bcb9119de03f258da6 Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sat, 4 Jul 2026 17:03:52 +0800 Subject: [PATCH 04/22] docs(file): sync path-type docs to the unified FilePath brand Replace AbsolutePathSchema / CanonicalExternalPath / CanonicalFilePath and the canonicalizeExternalPath wrapper with the single FilePathSchema / FilePath brand throughout the file-manager reference docs. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- docs/references/file/architecture.md | 6 +++--- docs/references/file/directory-tree.md | 2 +- .../file/file-manager-architecture.md | 18 +++++++++--------- .../docs/file-manager/migration-plan.md | 8 ++++---- .../docs/file-manager/rfc-file-manager.md | 10 +++++----- .../docs/file-manager/utils-file-migration.md | 6 +++--- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/references/file/architecture.md b/docs/references/file/architecture.md index 30860228060..e12335b1bb7 100644 --- a/docs/references/file/architecture.md +++ b/docs/references/file/architecture.md @@ -104,7 +104,7 @@ Pure FS primitives (src/main/utils/file/) — shared raw FS primitives, open to │ atomic write: atomicWriteFile / atomicWriteIfUnchanged / createAtomicWriteStream │ version: statVersion / contentHash (xxhash-h64) ├── shell.ts — system ops: open / showInFolder -├── path.ts — path utils: resolvePath / isPathInside / canWrite / isNotEmptyDir / canonicalizeExternalPath +├── path.ts — path utils: resolvePath / isPathInside / canWrite / isNotEmptyDir ├── metadata.ts — type detection: getFileType / isTextFile / mimeToExt ├── search.ts — directory search: listDirectory (ripgrep + fuzzy matching) ├── legacyFile.ts — shared legacy helpers (`getFileType(ext)` / `sanitizeFilename` / `getAllFiles` / `pathExists` / …); planned to be split into the modules above over time @@ -299,7 +299,7 @@ All operations that can act on any file (FileEntry or arbitrary path) **accept a | Method | Description | |---|---| | `createInternalEntry` / `batchCreateInternalEntries` | Create a new Cherry-owned FileEntry (writes to `{userData}/Data/Files/{id}.{ext}`; each call produces an independent new entry, no conflict possible) | -| `ensureExternalEntry` / `batchEnsureExternalEntries` | Pure upsert by `externalPath`—the entry point first `canonicalizeExternalPath(raw)` normalizes it (see `pathResolver.ts`); reuses the existing entry with the same path or inserts a new one. Idempotent by design—callers may safely repeat calls. No "restore" branch: external entries cannot be trashed. External rows carry no stored `size` (always `null`); live values come from `getMetadata`. | +| `ensureExternalEntry` / `batchEnsureExternalEntries` | Pure upsert by `externalPath`—the entry point first parses it through `FilePathSchema`, whose transform normalizes it via `canonicalizeAbsolutePath` (see `canonicalize.ts`); reuses the existing entry with the same path or inserts a new one. Idempotent by design—callers may safely repeat calls. No "restore" branch: external entries cannot be trashed. External rows carry no stored `size` (always `null`); live values come from `getMetadata`. | | `trash` / `restore` | Soft delete based on deletedAt (DB only). **Internal-origin only** — external-origin entries cannot be trashed (`fe_external_no_delete` CHECK); passing an external id throws. | | `batchTrash` / `batchRestore` | Batch versions of `trash` / `restore` — same internal-origin-only rule. | | `batchPermanentDelete` | Batch version of `permanentDelete`. | @@ -663,7 +663,7 @@ The File IPC adapter is a transport/dispatch layer. It may depend on FileManager | services/file/utils/* (file-module path/API helpers) | | | | Role: higher-level path-arm helpers with file-module semantics | -| Examples: resolvePhysicalPath, canonicalizeExternalPath, getMetadataByPath | +| Examples: resolvePhysicalPath, getMetadataByPath | | FS: may delegate to @main/utils/file/*; no DB lifecycle ownership | +-------------------------------------------------------------------------+ | DanglingCache (file_module singleton, not lifecycle) | diff --git a/docs/references/file/directory-tree.md b/docs/references/file/directory-tree.md index 95af018ccec..410aaf55939 100644 --- a/docs/references/file/directory-tree.md +++ b/docs/references/file/directory-tree.md @@ -168,7 +168,7 @@ The `file:tree:*` prefix places these alongside `File_Open` / `File_Read` / etc. ### 4.2 Validation -Both `File_TreeCreate` and `File_TreeDispose` validate their payloads through Zod at the handler boundary. `rootPath` must satisfy `AbsolutePathSchema` (non-empty, no null bytes, starts with `/` or `[A-Z]:\`). `options` is validated against `DirectoryTreeOptionsSchema` — the same schema whose `z.infer` produces the `DirectoryTreeOptions` TypeScript type, so wire shape and static type cannot drift. +Both `File_TreeCreate` and `File_TreeDispose` validate their payloads through Zod at the handler boundary. `rootPath` must satisfy `FilePathSchema` (non-empty, no null bytes, starts with `/` or `[A-Z]:\`). `options` is validated against `DirectoryTreeOptionsSchema` — the same schema whose `z.infer` produces the `DirectoryTreeOptions` TypeScript type, so wire shape and static type cannot drift. A malformed payload rejects with a `ZodError` Promise rejection at the IPC boundary; the renderer's `invoke()` rejects with the same error. There is no silent narrowing — handlers never see an unvalidated object. diff --git a/docs/references/file/file-manager-architecture.md b/docs/references/file/file-manager-architecture.md index 075d8ea7d4b..21362551ae5 100644 --- a/docs/references/file/file-manager-architecture.md +++ b/docs/references/file/file-manager-architecture.md @@ -55,9 +55,9 @@ CREATE UNIQUE INDEX fe_external_path_lower_unique_idx `fe_external_path_idx` (plain index on the raw `external_path`) backs byte-exact lookups (`findByExternalPath`, rename re-finds, path-resolution call sites). The functional index simultaneously serves the case-insensitive lookup path (`WHERE lower(externalPath) = lower(?)`) used by `findCaseInsensitivePeers` and enforces the uniqueness invariant — `ensureExternalEntry` MUST resolve case-collisions at the application layer before INSERT (see "Duplicate-entry detection on insert" below) because a DB-level rejection would otherwise surface as an opaque `SQLITE_CONSTRAINT`. Internal rows (`externalPath = NULL`) are exempt — SQLite treats multiple NULLs as distinct in a UNIQUE index. -**Canonical invariant of `externalPath`**: SQLite performs **byte-level** comparison on the raw `externalPath` column and cannot natively detect NFC ≡ NFD (Unicode). The functional index above handles case folding via `lower()` but does **not** apply Unicode normalization, so `externalPath` **must** be normalized via `canonicalizeExternalPath(raw)` before persistence—this is an application-layer invariant, with `ensureExternalEntry` and `fileEntryService.findByExternalPath` as mandatory call sites. +**Canonical invariant of `externalPath`**: SQLite performs **byte-level** comparison on the raw `externalPath` column and cannot natively detect NFC ≡ NFD (Unicode). The functional index above handles case folding via `lower()` but does **not** apply Unicode normalization, so `externalPath` **must** be normalized via `FilePathSchema`'s transform (`canonicalizeAbsolutePath`) before persistence—this is an application-layer invariant, with `ensureExternalEntry` and `fileEntryService.findByExternalPath` as mandatory call sites. -**Compile-time enforcement via `CanonicalExternalPath` brand**: `canonicalizeExternalPath()` returns a branded `CanonicalExternalPath` (TS phantom type, zero runtime cost; see `src/shared/data/types/file/fileEntry.ts`). Every DB read/write surface that filters by `externalPath` — today `findByExternalPath`, and any future DataApi endpoint or repository method — MUST accept this type, not a plain `string`. The type system then guarantees callers routed their input through the normalization function, eliminating the "forgot to canonicalize" class of bug that would silently miss all matches. +**Compile-time enforcement via the `FilePath` brand**: `FilePathSchema.parse()` returns a branded `FilePath` (Zod brand, zero runtime cost; see `src/shared/types/file/common.ts`). Every DB read/write surface that filters by `externalPath` — today `findByExternalPath`, and any future DataApi endpoint or repository method — MUST accept this type, not a plain `string`. The type system then guarantees callers routed their input through the normalization transform, eliminating the "forgot to canonicalize" class of bug that would silently miss all matches. | Source | Natively canonical | Relies on normalization to disambiguate | |---|---|---| @@ -74,17 +74,17 @@ CREATE UNIQUE INDEX fe_external_path_lower_unique_idx - Trailing separator trimming **Intentionally omitted** (deferred until concrete user feedback warrants the cost): -- `fs.realpath` as a step *inside* `canonicalizeExternalPath` itself (would require async FS IO at every canonicalization call site and a file-existence precondition). `fs.realpath` IS used on the `ensureExternalEntry` collision path described below — that is a per-collision probe, not a per-canonicalize step. +- `fs.realpath` as a step *inside* `canonicalizeAbsolutePath` itself (would require async FS IO at every canonicalization call site and a file-existence precondition). `fs.realpath` IS used on the `ensureExternalEntry` collision path described below — that is a per-collision probe, not a per-canonicalize step. - Symlink target merging at canonicalize time - Windows 8.3 short-name resolution -See the JSDoc for `canonicalizeExternalPath` in `src/main/services/file/utils/pathResolver.ts` for the detailed contract. +See the JSDoc for `canonicalizeAbsolutePath` in `src/shared/utils/file/canonicalize.ts` for the detailed contract. #### Rule evolution discipline -Because the canonical form is **application-layer logic**, not DB schema, any change to `canonicalizeExternalPath`'s normalization steps desynchronizes historical rows (written under the old rule) from new queries (running under the new rule). This produces a silent failure mode: byte-compare misses, the user sees "my file is in the library but the app says it isn't", and `ensureExternalEntry` inserts a duplicate. +Because the canonical form is **application-layer logic**, not DB schema, any change to `canonicalizeAbsolutePath`'s normalization steps desynchronizes historical rows (written under the old rule) from new queries (running under the new rule). This produces a silent failure mode: byte-compare misses, the user sees "my file is in the library but the app says it isn't", and `ensureExternalEntry` inserts a duplicate. -**Rule**: modifying `canonicalizeExternalPath` ≡ ship a paired Drizzle migration that re-canonicalizes every existing `file_entry` row with `origin='external'` in the **same PR**. No exceptions — even if the new rule is claimed "strictly more permissive", the byte-compare will still miss. +**Rule**: modifying `canonicalizeAbsolutePath` ≡ ship a paired Drizzle migration that re-canonicalizes every existing `file_entry` row with `origin='external'` in the **same PR**. No exceptions — even if the new rule is claimed "strictly more permissive", the byte-compare will still miss. When a rule change additionally collapses previously-distinct strings to the same canonical form (e.g. adding `fs.realpath` merges APFS case-insensitive duplicates), the migration MUST also merge the colliding rows. The rules below are prescriptive; follow them exactly rather than improvising per-migration. @@ -683,8 +683,8 @@ Query: `WHERE deletedAt < now() - retentionMs` → batch permanentDelete. |---|---| | unlink fails on permanentDelete internal (file already missing, permission issue) | Log warn; the DB row is already gone, so the failure surfaces only as an orphan blob that the next user-triggered orphan sweep will reclaim | | permanentDelete on external | DB-only by design; the user's file at `externalPath` is never touched — Cherry owns only the reference | -| `ensureExternalEntry(path)` when an entry for the same path already exists | Entry point first calls `canonicalizeExternalPath(raw)`; upsert returns the existing row. External entries cannot be trashed, so there is no "restore" branch. | -| **Two entries for the same file due to case / NFC differences** (macOS APFS, Windows NTFS, or NFD ↔ NFC input) | NFC closed by `canonicalizeExternalPath`; case-collision rejected at INSERT by the DB functional unique index plus the `fs.realpath`-based reuse-or-throw decision in `ensureExternalEntry` (see §1.2 "Duplicate-entry detection on insert"). | +| `ensureExternalEntry(path)` when an entry for the same path already exists | Entry point first calls `FilePathSchema.parse(raw)`; upsert returns the existing row. External entries cannot be trashed, so there is no "restore" branch. | +| **Two entries for the same file due to case / NFC differences** (macOS APFS, Windows NTFS, or NFD ↔ NFC input) | NFC closed by `FilePathSchema`'s canonicalization; case-collision rejected at INSERT by the DB functional unique index plus the `fs.realpath`-based reuse-or-throw decision in `ensureExternalEntry` (see §1.2 "Duplicate-entry detection on insert"). | | External file at original path externally replaced with a different file | Cherry does not check content consistency (best-effort). `name` / `ext` on the row are derived from `externalPath` and do not change; `size` is always served live by `getMetadata`. DanglingCache flips to `'present'` on the next stat, so the UI just renders the new file under the existing reference. | | A trashed entry is permanently externally deleted and then restored | Appears dangling (DanglingCache returns missing on next check), UI shows failed style | | External write with permission error / disk full on target path | Throw without polluting DB; caller decides retry or user notification | @@ -1375,7 +1375,7 @@ This checklist is the canonical addition procedure. A PR introducing a new origi | Location | Change required | |---|---| | `src/main/services/file/utils/pathResolver.ts` → `resolvePhysicalPath` | Add the new `entry.origin` branch; decide storage layout | -| Same file → `canonicalizeExternalPath` | If the new variant is path-based and distinct from `'external'`, decide whether it shares the canonical form or needs its own normalization + brand | +| `src/shared/utils/file/canonicalize.ts` → `canonicalizeAbsolutePath` (via `FilePathSchema`'s transform) | If the new variant is path-based and distinct from `'external'`, decide whether it shares the canonical form or needs its own normalization + brand | ### 13.4 Behavior Policy Matrix diff --git a/v2-refactor-temp/docs/file-manager/migration-plan.md b/v2-refactor-temp/docs/file-manager/migration-plan.md index 46f9399bb3e..da52d095d05 100644 --- a/v2-refactor-temp/docs/file-manager/migration-plan.md +++ b/v2-refactor-temp/docs/file-manager/migration-plan.md @@ -1539,7 +1539,7 @@ async function migrateFileEntry(oldFile: DexieFileRow): Promise { size: oldFile.size, externalPath: oldFile.origin === "external" - ? canonicalizeExternalPath(oldFile.path) + ? canonicalizeAbsolutePath(oldFile.path) : null, deletedAt: null, // Dexie 没软删除字段;external 也不允许 trashed(fe_external_no_delete) createdAt: toMs(oldFile.created_at), // ISO → ms @@ -1550,7 +1550,7 @@ async function migrateFileEntry(oldFile: DexieFileRow): Promise { **External path 去重(强制)**:新 schema 的 `UNIQUE(externalPath)` 禁止同路径两条行。如果 Dexie 里存在多条指向同一 canonical path 的 external FileMetadata(由 case / NFD / 拼写差异合并后),FileMigrator 必须: -1. 按 `canonicalizeExternalPath(path)` 分组 +1. 按 `canonicalizeAbsolutePath(path)` 分组 2. 每组保留一条(建议取 `createdAt` 最早的)作为 surviving row 3. 把组内其他 id 收集到 id-remap 表,在迁移 `file_ref`(以及其他引用 FileMetadata.id 的业务表)时将旧 id 重路由到 surviving id 4. 被合并掉的 FileMetadata.id 不产生 `file_entry` 行 @@ -1651,7 +1651,7 @@ ctx.sharedData.set('fileMigrator.idRemap', /* ReadonlyMap */ ctx.sharedData.set('fileMigrator.knownIds', /* ReadonlySet */) ``` -- **`idRemap`** 服务 external 去重——`canonicalizeExternalPath` 合并掉的 loser id 在表里映射到 surviving id。internal 文件**不在表里**(一对一保留)。 +- **`idRemap`** 服务 external 去重——`canonicalizeAbsolutePath` 合并掉的 loser id 在表里映射到 surviving id。internal 文件**不在表里**(一对一保留)。 - **`knownIds`** 是所有成功写入 `file_entry` 的 id 全集,供业务 migrator 校验 "这个 fileId 是否真的迁过去了"。 业务 migrator 通过这两份产物访问 file 子系统,**不直接查 DB**(保持迁移期单向数据流;同时避免对 fileEntryService 启动顺序产生隐式依赖)。 @@ -1698,7 +1698,7 @@ async function migrateOneMessageBlock(block) { | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `dexieExport.tableExists('files')` 返回 false | `recordWarning` 后跳过;FileMigrator 不产生任何 file_entry。业务 migrator 后续遇到 file id 查 `knownIds` miss → warn-skip ref(链式安全) | | 单条 file 字段缺失(`origin_name` undefined 等) | `recordWarning` + skip 该行;不中止迁移 | -| `canonicalizeExternalPath` 抛出(含 null byte) | **严重错误**:抛 `MigrationFatalError` 中止——v1 数据已被注入恶意路径,需人工调查 | +| `canonicalizeAbsolutePath` 抛出(含 null byte) | **严重错误**:抛 `MigrationFatalError` 中止——v1 数据已被注入恶意路径,需人工调查 | | External 去重时 surviving 选择失败 | 抛 `MigrationFatalError` 中止 | | `file_entry` INSERT 违反 schema CHECK(origin/size/externalPath 三元约束)| 抛 `MigrationFatalError` 中止——mapping 逻辑有 bug | | §2.10.2 物理抽样失败率 > 50% | 抛 `MigrationFatalError` 中止(前条文已规约) | diff --git a/v2-refactor-temp/docs/file-manager/rfc-file-manager.md b/v2-refactor-temp/docs/file-manager/rfc-file-manager.md index 0800656d6ab..edaf9aaa5c9 100644 --- a/v2-refactor-temp/docs/file-manager/rfc-file-manager.md +++ b/v2-refactor-temp/docs/file-manager/rfc-file-manager.md @@ -381,7 +381,7 @@ async function ensureExternalEntry( // Phase 1b.1 同步廉价 canonicalize: path.resolve + NFC + trailing-sep strip. // 不含 fs.realpath(case-insensitive FS 去重由 Phase 2 视用户反馈补)。 // 是 upsert/查询的唯一 key 来源。 - const canonicalPath = canonicalizeExternalPath(params.externalPath); + const canonicalPath = canonicalizeAbsolutePath(params.externalPath); // External 恒非 trashed(fe_external_no_delete CHECK),所以不需要 includeTrashed。 const existing = await fileEntryService.findByExternalPath(canonicalPath); if (existing) return existing; // name/ext 来自 externalPath,不会漂移;size 不存 @@ -1025,7 +1025,7 @@ Phase 1a ──→ Phase 1b.1 ──→ Phase 1b.2 ──→ Phase 1b.3 ── **不在本期**: - FileManager / ops / internal / watcher / danglingCache / orphanSweep 任何运行时逻辑 -- `canonicalizeExternalPath` 实现(契约与签名 Phase 1a 锁定,实现在 1b.1) +- `canonicalizeAbsolutePath` 实现(契约与签名 Phase 1a 锁定,实现在 1b.1) - `versionCache` 运行时(只定义 interface) - 任何 Dexie → SQLite 数据搬运 - renderer 切换调用路径 @@ -1041,7 +1041,7 @@ Phase 1a ──→ Phase 1b.1 ──→ Phase 1b.2 ──→ Phase 1b.3 ── - `FileEntryService` / `FileRefService` CRUD 实现(纯 DB;read 路径完整,write 可保留 stub) - `ops/fs.ts` 的 `read` / `stat` / `exists` / `metadata` / `contentHash`(xxhash-128) - `ops/path.ts` 的 `resolvePhysicalPath`(已存在)+ `isUnderInternalStorage` guard -- `canonicalizeExternalPath` 真实现(`path.resolve` + NFC + trailing-sep strip)+ 8-10 条边界测试(NFC/NFD / trailing / `./a/../a` / Windows `\\` / 盘符大小写) +- `canonicalizeAbsolutePath` 真实现(`path.resolve` + NFC + trailing-sep strip)+ 8-10 条边界测试(NFC/NFD / trailing / `./a/../a` / Windows `\\` / 盘符大小写) - `FileManager.get*` / `read*` / `getMetadata` / `getUrl` / `findByExternalPath` / `ensureExternalEntry`(upsert-only,不写 FS) - `internal/content/read.ts` / `internal/content/hash.ts`(含 `*ByPath` 变体) - `dispatchHandle(handle, byEntryFn, byPathFn)` helper 的读路径分派骨架 @@ -1199,7 +1199,7 @@ Vercel AI SDK Files API 稳定后: | Painting 的 file_ref 暂缺 | 文件页无法追溯 painting 引用 | 文件条目本身已存在可访问;随 Painting 重构补建 | | Phase 1 内 deferred-to-Phase-2 stub 的 `throw NotImplemented` 影响上游 | 开发期阻塞 | Phase 1 不切换 renderer 调用路径;剩余 stub(`fs.compressImage` / `path.resolvePath` / `path.isNotEmptyDir` / `shell.open` / `shell.showInFolder` / `search.listDirectory`)在 Phase 2 各自消费方迁移时一并实现并 feature-flag 切换 | | External entry 物理文件外部丢失 | entry 变 dangling | DanglingCache + File IPC `getDanglingState` / `batchGetDanglingStates` 给 UI 展示;不自动清理 file_ref(用户手动处理) | -| `externalPath` 大小写不敏感 FS 导致同文件双 entry(macOS APFS / Windows NTFS) | 文件页用户看到两份同文件、file_ref 分裂 | Phase 1b.1 `canonicalizeExternalPath` 做同步廉价规范化(resolve + NFC + trailing-sep),**刻意不做** `fs.realpath` case 去重——支配性来源(dialog / drag-drop)本就给 OS-canonical 值;收到真实用户报告后再 additively 扩展 + one-off migration 合并重复行 | +| `externalPath` 大小写不敏感 FS 导致同文件双 entry(macOS APFS / Windows NTFS) | 文件页用户看到两份同文件、file_ref 分裂 | Phase 1b.1 `canonicalizeAbsolutePath` 做同步廉价规范化(resolve + NFC + trailing-sep),**刻意不做** `fs.realpath` case 去重——支配性来源(dialog / drag-drop)本就给 OS-canonical 值;收到真实用户报告后再 additively 扩展 + one-off migration 合并重复行 | --- @@ -1358,7 +1358,7 @@ relinkExternalEntry(id: FileEntryId, newPath: FilePath): Promise; - **不触碰 FS**——与 `rename` 明确区分("追认已移动" vs "主动移动") - 保留 `id` 与所有 `file_ref` -- 调用 `canonicalizeExternalPath` 归一化新路径 +- 调用 `canonicalizeAbsolutePath` 归一化新路径 - 同步更新 DanglingCache 反向索引(旧 path 移除、新 path 添加、状态重置) - `name` / `ext` 作为 `externalPath` 的投影自动跟更新 diff --git a/v2-refactor-temp/docs/file-manager/utils-file-migration.md b/v2-refactor-temp/docs/file-manager/utils-file-migration.md index bbac0e85b2c..b3d376e27c9 100644 --- a/v2-refactor-temp/docs/file-manager/utils-file-migration.md +++ b/v2-refactor-temp/docs/file-manager/utils-file-migration.md @@ -18,7 +18,7 @@ src/main/utils/file/ ├── legacyFile.ts # v1 helpers(本文档规划如何把它拆干净) ├── fs.ts # v2 原语:read / write / stat / copy / move / remove / removeDir / atomicWriteFile / statVersion / contentHash ├── metadata.ts # v2 原语:getFileType(path) / isTextFile(path) / mimeToExt(mime) -├── path.ts # v2 原语:resolvePath / isPathInside / canWrite / isNotEmptyDir / canonicalizeExternalPath / resolvePhysicalPath / getExtSuffix +├── path.ts # v2 原语:resolvePath / isPathInside / canWrite / isNotEmptyDir / canonicalizeAbsolutePath / resolvePhysicalPath / getExtSuffix ├── search.ts # v2 原语:listDirectory (ripgrep + fuzzy) └── shell.ts # v2 原语:open / showInFolder ``` @@ -45,11 +45,11 @@ Notes 域(未来 `src/main/services/notes/`)承接 Notes 树扫描、笔记 | 函数 | Callers | 目标位置 | 新签名(建议) | Phase | 备注 | |---|---|---|---|---|---| -| `resolveAndValidatePath(baseDir, relativePath)` | 2 | `path.ts` | 同名,签名不变 | **1b.1** | 防路径穿越;`path.ts` 实现 canonicalizeExternalPath 时一起落实 | +| `resolveAndValidatePath(baseDir, relativePath)` | 2 | `path.ts` | 同名,签名不变 | **1b.1** | 防路径穿越;`path.ts` 实现 canonicalizeAbsolutePath 时一起落实 | | `untildify(pathWithTilde)` | 2 | `path.ts` | 同名 | **1b.1** | `~` 展开;纯 string op,无 FS | | `isPathInside(childPath, parentPath)` | 9 | `path.ts` | 同名 | **1b.1** | 已作为 `path.ts` 原语列在架构 §7;高 callers 数,保持 API 稳定 | -**合并手法**:Phase 1b.1 实现 `path.ts` 的 `canonicalizeExternalPath` / `isPathInside` 时,把上述函数原样内联进 `path.ts`,`legacyFile.ts` 留下 `export { ... } from './path'` 过渡 re-export 一轮(避免 9 处 `isPathInside` caller 一次性全改)。Phase 2 统一删 re-export。 +**合并手法**:Phase 1b.1 实现 `path.ts` 的 `canonicalizeAbsolutePath` / `isPathInside` 时,把上述函数原样内联进 `path.ts`,`legacyFile.ts` 留下 `export { ... } from './path'` 过渡 re-export 一轮(避免 9 处 `isPathInside` caller 一次性全改)。Phase 2 统一删 re-export。 ### 2.2 权限 / 存在性探测 → `@main/utils/file/path` From d561643aa7bcb57e6e8a1ea5a6917d2d2a5c86bd Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sat, 4 Jul 2026 17:50:55 +0800 Subject: [PATCH 05/22] refactor(file): guard ArtifactPane render parse and tighten findByExternalPath param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArtifactPane's HTML baseUrl computation used FilePathSchema.parse during render, which throws on a non-absolute joined path; switch to safeParse and fall back to undefined (HtmlPreviewFrame's default base) on failure, matching the safeParse guard pattern used by the other v2 FilePath render sites in this branch. FileManager.findByExternalPath took a plain string but internally parsed it with FilePathSchema.parse, which now throws on non-absolute input instead of resolving it — the string param no longer told the truth. Only test callers exist, so retype the param to FilePath (dropping the now-redundant inner parse) and update the integration test to construct FilePath values via FilePathSchema.parse at the call site. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- src/main/services/file/FileManager.ts | 4 ++-- .../services/file/__tests__/FileManager.integration.test.ts | 6 +++--- src/renderer/components/chat/panes/ArtifactPane.tsx | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/services/file/FileManager.ts b/src/main/services/file/FileManager.ts index 9d2b2a34ea4..9311f48a3f5 100644 --- a/src/main/services/file/FileManager.ts +++ b/src/main/services/file/FileManager.ts @@ -823,8 +823,8 @@ export class FileManager extends BaseService implements IFileManager { return this.deps.fileEntryService.findById(id) } - async findByExternalPath(rawPath: string): Promise { - return this.deps.fileEntryService.findByExternalPath(FilePathSchema.parse(rawPath)) + async findByExternalPath(path: FilePath): Promise { + return this.deps.fileEntryService.findByExternalPath(path) } async ensureExternalEntry(params: EnsureExternalEntryParams): Promise { diff --git a/src/main/services/file/__tests__/FileManager.integration.test.ts b/src/main/services/file/__tests__/FileManager.integration.test.ts index a5e6e419ef3..4303d2c94f8 100644 --- a/src/main/services/file/__tests__/FileManager.integration.test.ts +++ b/src/main/services/file/__tests__/FileManager.integration.test.ts @@ -5,7 +5,7 @@ import path from 'node:path' import { application } from '@application' import { fileEntryTable } from '@data/db/schemas/file' import { BaseService } from '@main/core/lifecycle' -import type { FileEntryId } from '@shared/data/types/file' +import { type FileEntryId, FilePathSchema } from '@shared/data/types/file' import { fileErrorCodes } from '@shared/ipc/errors/file' import { setupTestDatabase } from '@test-helpers/db' import { MockMainCacheServiceUtils } from '@test-mocks/main/CacheService' @@ -122,12 +122,12 @@ describe('FileManager (integration)', () => { }) // Canonical lookup - const found = await fm.findByExternalPath(`${file}/`) // trailing slash → canonicalize strips + const found = await fm.findByExternalPath(FilePathSchema.parse(`${file}/`)) // trailing slash → canonicalize strips expect(found?.id).toBe(id) // NFC re-normalization survives a synthesized NFD form const nfdFile = file.normalize('NFD') - const foundNfc = await fm.findByExternalPath(nfdFile) + const foundNfc = await fm.findByExternalPath(FilePathSchema.parse(nfdFile)) expect(foundNfc?.id).toBe(id) // Content hash works for external entries diff --git a/src/renderer/components/chat/panes/ArtifactPane.tsx b/src/renderer/components/chat/panes/ArtifactPane.tsx index d6182289cac..8719115c110 100644 --- a/src/renderer/components/chat/panes/ArtifactPane.tsx +++ b/src/renderer/components/chat/panes/ArtifactPane.tsx @@ -445,12 +445,13 @@ export function ArtifactFilePreview({ } if (isHtmlFile(filePath)) { + const parsedHtmlBase = FilePathSchema.safeParse(joinPath(workspacePath, filePath)) return ( ) } From cfb38fb19d8a31ba9ce757e86e1df82e60094ade Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sat, 4 Jul 2026 22:38:04 +0800 Subject: [PATCH 06/22] refactor(file): keep FileHandle schema/type-wiring TODO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema↔type wiring for FileHandle (TODO #1) is not complete: wiring the `path` field to `FilePathSchema` fixed only that field, while `as FileHandle` casts remain at the IPC handlers and `FileManager` (parse boundary), and `Base64String` / `UrlString` still have no runtime schemas. Restore the TODO that tracks this so it isn't lost. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- src/shared/types/file/handle.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/types/file/handle.ts b/src/shared/types/file/handle.ts index 2e498262b07..7b5dc9c704e 100644 --- a/src/shared/types/file/handle.ts +++ b/src/shared/types/file/handle.ts @@ -56,4 +56,5 @@ export const FilePathHandleSchema = z.strictObject({ }) export const FileHandleSchema = z.discriminatedUnion('kind', [FileEntryHandleSchema, FilePathHandleSchema]) +// TODO: 1. Wire schema and types, so no as cast needed // TODO: 2. Add brand for FileHandle since factory function has been used From fad407be56e5167b127b61664ea94b51bc80402f Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sun, 5 Jul 2026 07:56:27 +0800 Subject: [PATCH 07/22] refactor(file-types): make FilePath shape-only, add CanonicalFilePath brand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FilePath no longer canonicalizes on parse: FilePathSchema drops the .transform(canonicalizeAbsolutePath) and now validates absolute-path shape only, returning its input unchanged. This fixes the NFC-rewrite regressions the transform introduced — it broke NFD-named file imports on Linux by rewriting read-source paths, and it rewrote rootPath in a way that contradicted the watcher/tree byte-for-byte exemptions. Canonicalization is now an explicit, separate concept: a new canonicalizeFilePath() factory in @shared/utils/file/canonicalize produces a CanonicalFilePath sub-brand (a FilePath additionally proven canonical), used only at the external-path write/lookup boundary — FileEntryService's findByExternalPath / findCaseInsensitivePeers / setExternalPathAndName(Tx) params, ensureExternal, rename, and FileManager.findByExternalPath all flow CanonicalFilePath. file_entry.externalPath validates canonical-equivalence on read (non-canonical rows are warn-skipped, never silently repaired) and is branded CanonicalFilePath. CanonicalFilePath uses a string-literal brand key (not a unique symbol) so the preload WindowApiType aggregate does not trip TS2527/TS4023. The batch-ensure dedup loop now trusts the branded param and keys its memo on externalPath directly (no FilePathSchema re-parse). The eslint no-as-filepath watcher/tree exemption comment is re-justified on raw-OS-path grounds now that the canonicalize rationale is moot. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- eslint.config.mjs | 11 +-- src/main/data/services/FileEntryService.ts | 30 ++++---- .../__tests__/FileEntryService.test.ts | 74 +++++++++++++++---- src/main/services/file/FileManager.ts | 18 +++-- .../services/file/internal/entry/create.ts | 40 +++++----- .../services/file/internal/entry/rename.ts | 4 +- src/shared/data/types/file/fileEntry.ts | 28 +++++-- .../file/__tests__/FilePathSchema.test.ts | 34 ++++----- src/shared/types/file/common.ts | 34 +++++++-- src/shared/types/file/index.ts | 1 + .../utils/file/__tests__/canonicalize.test.ts | 21 +++++- src/shared/utils/file/canonicalize.ts | 27 +++++-- 12 files changed, 223 insertions(+), 99 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 98562778d95..b3d11568b91 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -213,12 +213,13 @@ export default defineConfig([ } }, // FilePath brand integrity — `as FilePath` forges the brand, skipping - // FilePathSchema's canonicalization (NFC normalize + segment resolution). - // Production code must build a FilePath via FilePathSchema.parse(value). + // FilePathSchema's shape validation. Production code must build a FilePath + // via FilePathSchema.parse(value). // Exemptions: test fixtures; and two deliberate raw-OS-path regimes — the - // directory watcher emits the raw path by design, and the tree builder - // compares rootPath against raw absPaths — where canonicalizing would - // reintroduce the NFC divergence this brand exists to prevent. + // directory watcher (watcher/**) and the tree builder (tree/**) hold raw + // chokidar/OS event paths that are compared byte-for-byte against event + // paths and are trusted as-is, so they `as FilePath` rather than routing + // through validation. { files: ['src/**/*.{ts,tsx}'], ignores: [ diff --git a/src/main/data/services/FileEntryService.ts b/src/main/data/services/FileEntryService.ts index a5c1e96ed09..31297ec4cff 100644 --- a/src/main/data/services/FileEntryService.ts +++ b/src/main/data/services/FileEntryService.ts @@ -32,7 +32,7 @@ import type { FileEntryListResponse, FileEntryStats } from '@shared/data/api/sch import type { FileEntry, FileEntryId, FileEntryOrigin } from '@shared/data/types/file' import { ExternalEntrySchema, FileEntrySchema, InternalEntrySchema, SafeNameSchema } from '@shared/data/types/file' import { chatMessageSourceType, paintingSourceType } from '@shared/data/types/file/ref' -import type { FilePath } from '@shared/types/file' +import type { CanonicalFilePath } from '@shared/types/file' import { and, asc, count, eq, isNotNull, isNull, type SQL, sql, type SQLWrapper } from 'drizzle-orm' import { v7 as uuidv7 } from 'uuid' import * as z from 'zod' @@ -114,12 +114,12 @@ export interface FileEntryService { /** * Look up an external entry by canonical `externalPath`. Returns `null` when - * no row matches. The `FilePath` brand forces callers through - * `FilePathSchema.parse()` at compile time — raw `string` values are - * not assignable here, which prevents the "caller forgot to canonicalize" - * class of bug that would silently miss all matches. + * no row matches. The `CanonicalFilePath` brand forces callers through + * `canonicalizeFilePath()` at compile time — raw `string` / bare `FilePath` + * values are not assignable here, which prevents the "caller forgot to + * canonicalize" class of bug that would silently miss all matches. */ - findByExternalPath(canonicalPath: FilePath): FileEntry | null + findByExternalPath(canonicalPath: CanonicalFilePath): FileEntry | null /** * Return external entries whose `externalPath` matches `canonicalPath` @@ -147,7 +147,7 @@ export interface FileEntryService { * * Un-parseable rows are skipped with a warning (see `rowToFileEntrySafe`). */ - findCaseInsensitivePeers(canonicalPath: FilePath): FileEntry[] + findCaseInsensitivePeers(canonicalPath: CanonicalFilePath): FileEntry[] /** * Flat listing. Trashed filter defaults to "active only" when `inTrash` is omitted. @@ -217,10 +217,10 @@ export interface FileEntryService { * doing it as a single statement keeps the (path, name) pair consistent under * partial-failure scenarios (transient lock, schema constraint). */ - setExternalPathAndName(id: FileEntryId, externalPath: FilePath, name: string): FileEntry + setExternalPathAndName(id: FileEntryId, externalPath: CanonicalFilePath, name: string): FileEntry /** Tx-scoped variant of `setExternalPathAndName` for composing write flows. */ - setExternalPathAndNameTx(tx: DbOrTx, id: FileEntryId, externalPath: FilePath, name: string): FileEntry + setExternalPathAndNameTx(tx: DbOrTx, id: FileEntryId, externalPath: CanonicalFilePath, name: string): FileEntry /** Remove the row (CASCADE drops dependent persistent file refs). No-op if already gone. */ delete(id: FileEntryId): void @@ -387,7 +387,7 @@ class FileEntryServiceImpl implements FileEntryService { return entry } - findByExternalPath(canonicalPath: FilePath): FileEntry | null { + findByExternalPath(canonicalPath: CanonicalFilePath): FileEntry | null { const rows = this.getDb() .select() .from(fileEntryTable) @@ -397,7 +397,7 @@ class FileEntryServiceImpl implements FileEntryService { return rows.length === 0 ? null : rowToFileEntry(rows[0]) } - findCaseInsensitivePeers(canonicalPath: FilePath): FileEntry[] { + findCaseInsensitivePeers(canonicalPath: CanonicalFilePath): FileEntry[] { const rows = this.getDb() .select() .from(fileEntryTable) @@ -604,15 +604,15 @@ class FileEntryServiceImpl implements FileEntryService { * the only sanctioned mutation site for `externalPath`. Used by the rename * flow so the (path, name) pair stays consistent under failure. */ - setExternalPathAndName(id: FileEntryId, externalPath: FilePath, name: string): FileEntry { + setExternalPathAndName(id: FileEntryId, externalPath: CanonicalFilePath, name: string): FileEntry { return this.setExternalPathAndNameTx(this.getDb(), id, externalPath, name) } - setExternalPathAndNameTx(tx: DbOrTx, id: FileEntryId, externalPath: FilePath, name: string): FileEntry { + setExternalPathAndNameTx(tx: DbOrTx, id: FileEntryId, externalPath: CanonicalFilePath, name: string): FileEntry { // Same pre-SQL validation rationale as `update` above; an unsafe `name` // would corrupt the row past `rowToFileEntry` parse. `externalPath` needs - // no re-parse here: the `FilePath` brand can only be produced by - // `FilePathSchema.parse()`, so callers already proved canonicalization at + // no re-parse here: the `CanonicalFilePath` brand can only be produced by + // `canonicalizeFilePath()`, so callers already proved canonicalization at // construction time — re-running it would just re-verify a fact the type // system already guarantees. SafeNameSchema.parse(name) diff --git a/src/main/data/services/__tests__/FileEntryService.test.ts b/src/main/data/services/__tests__/FileEntryService.test.ts index 1da125d24d0..50fd35ec026 100644 --- a/src/main/data/services/__tests__/FileEntryService.test.ts +++ b/src/main/data/services/__tests__/FileEntryService.test.ts @@ -5,7 +5,7 @@ import { paintingTable } from '@data/db/schemas/painting' import { topicTable } from '@data/db/schemas/topic' import { DataApiError, ErrorCode } from '@shared/data/api' import type { FileEntryId } from '@shared/data/types/file' -import type { FilePath } from '@shared/types/file' +import type { CanonicalFilePath, FilePath } from '@shared/types/file' import { setupTestDatabase } from '@test-helpers/db' import { MockMainDbServiceExport, MockMainDbServiceUtils } from '@test-mocks/main/DbService' import { mockMainLoggerService } from '@test-mocks/MainLoggerService' @@ -118,13 +118,13 @@ describe('FileEntryService', () => { updatedAt: now }) - const entry = fileEntryService.findByExternalPath('/Users/me/doc.pdf' as FilePath) + const entry = fileEntryService.findByExternalPath('/Users/me/doc.pdf' as CanonicalFilePath) expect(entry?.id).toBe(id) expect(entry?.origin).toBe('external') }) it('returns null when no row matches', async () => { - const result = fileEntryService.findByExternalPath('/Users/me/nonexistent.pdf' as FilePath) + const result = fileEntryService.findByExternalPath('/Users/me/nonexistent.pdf' as CanonicalFilePath) expect(result).toBeNull() }) @@ -143,7 +143,7 @@ describe('FileEntryService', () => { updatedAt: now }) - const result = fileEntryService.findByExternalPath('/Users/me/A.TXT' as FilePath) + const result = fileEntryService.findByExternalPath('/Users/me/A.TXT' as CanonicalFilePath) expect(result).toBeNull() }) }) @@ -168,13 +168,13 @@ describe('FileEntryService', () => { updatedAt: now }) - const peers = fileEntryService.findCaseInsensitivePeers('/Users/me/a.txt' as FilePath) + const peers = fileEntryService.findCaseInsensitivePeers('/Users/me/a.txt' as CanonicalFilePath) expect(peers).toHaveLength(1) expect(peers[0]?.id).toBe('019606a0-0000-7000-8000-000000000020') }) it('returns empty array when no rows match', async () => { - const peers = fileEntryService.findCaseInsensitivePeers('/zzz/none.txt' as FilePath) + const peers = fileEntryService.findCaseInsensitivePeers('/zzz/none.txt' as CanonicalFilePath) expect(peers).toEqual([]) }) @@ -1177,7 +1177,11 @@ describe('FileEntryService', () => { const original = fileEntryService.getById(id) await new Promise((r) => setTimeout(r, 5)) - const updated = fileEntryService.setExternalPathAndName(id, '/Users/me/new-doc.pdf' as FilePath, 'new-doc') + const updated = fileEntryService.setExternalPathAndName( + id, + '/Users/me/new-doc.pdf' as CanonicalFilePath, + 'new-doc' + ) expect(updated.id).toBe(id) if (updated.origin !== 'external') throw new Error('expected external entry') @@ -1196,7 +1200,7 @@ describe('FileEntryService', () => { const missing = '019606a0-0000-7000-8000-000000000dff' as FileEntryId let err: unknown try { - fileEntryService.setExternalPathAndName(missing, '/Users/me/ghost.pdf' as FilePath, 'ghost') + fileEntryService.setExternalPathAndName(missing, '/Users/me/ghost.pdf' as CanonicalFilePath, 'ghost') } catch (e) { err = e } @@ -1219,7 +1223,7 @@ describe('FileEntryService', () => { }) expect(() => - fileEntryService.setExternalPathAndName(entry.id, '/Users/me/legit.txt' as FilePath, '../evil') + fileEntryService.setExternalPathAndName(entry.id, '/Users/me/legit.txt' as CanonicalFilePath, '../evil') ).toThrow() const [raw] = await dbh.db.select().from(fileEntryTable).where(eq(fileEntryTable.id, entry.id)) @@ -1252,7 +1256,7 @@ describe('FileEntryService', () => { // unexpected and bubble it up. let err: Error | null = null try { - fileEntryService.setExternalPathAndName(b.id, '/Users/me/a.txt' as FilePath, 'a') + fileEntryService.setExternalPathAndName(b.id, '/Users/me/a.txt' as CanonicalFilePath, 'a') } catch (e) { err = e as Error } @@ -1281,7 +1285,11 @@ describe('FileEntryService', () => { ext: 'txt', externalPath: '/Users/me/ext-tx.txt' }) - fileEntryService.setExternalPathAndName(external.id, '/Users/me/ext-tx-renamed.txt' as FilePath, 'ext-tx-renamed') + fileEntryService.setExternalPathAndName( + external.id, + '/Users/me/ext-tx-renamed.txt' as CanonicalFilePath, + 'ext-tx-renamed' + ) // create/update/delete/setExternalPathAndName are each a single autocommit statement under // better-sqlite3, so they write via getDb() directly and never wrap withWriteTx. If any grows @@ -1578,7 +1586,7 @@ describe('FileEntryService', () => { mockMainLoggerService.warn.mockClear() // Corrupt match → excluded with one warning, not a throw. - const badPeers = fileEntryService.findCaseInsensitivePeers('/users/me/bad-peer.txt' as FilePath) + const badPeers = fileEntryService.findCaseInsensitivePeers('/users/me/bad-peer.txt' as CanonicalFilePath) expect(badPeers).toEqual([]) expect(mockMainLoggerService.warn).toHaveBeenCalledTimes(1) expect(mockMainLoggerService.warn).toHaveBeenCalledWith( @@ -1587,8 +1595,48 @@ describe('FileEntryService', () => { ) // Good rows still surface through the same method. - const goodPeers = fileEntryService.findCaseInsensitivePeers('/users/me/good-peer.txt' as FilePath) + const goodPeers = fileEntryService.findCaseInsensitivePeers('/users/me/good-peer.txt' as CanonicalFilePath) expect(goodPeers.map((e) => e.id)).toEqual([goodExternalId]) }) }) + + describe('non-canonical externalPath rejection (read-time contract)', () => { + it('warn-skips an external row whose stored externalPath is not canonical (NFD form)', async () => { + // Non-canonical externalPath rows are rejected on read — no silent repair; + // lookup-by-path requires a re-canonicalization migration. externalPath is + // guaranteed canonical only at WRITE time (via canonicalizeFilePath in + // ensureExternal / rename); a row inserted directly with a non-canonical + // value (bypassing the service write path) fails the schema's + // canonical-equivalence refine and is warn-skipped by rowToFileEntrySafe + // rather than returned or silently rewritten. + const id = '019606a0-0000-7000-8000-00000000ab01' as FileEntryId + const now = Date.now() + // "café.pdf" written in NFD: base 'e' + combining acute accent (U+0301). + // canonicalizeAbsolutePath NFC-folds this to a single U+00E9, so the stored + // bytes differ from their canonical form. ASCII \u escapes keep the literal + // stable — raw combining marks get silently re-normalized by tooling. + const nonCanonicalPath = '/Users/me/cafe\u0301.pdf' + await dbh.db.insert(fileEntryTable).values({ + id, + origin: 'external', + name: 'cafe', + ext: 'pdf', + size: null, + externalPath: nonCanonicalPath, + deletedAt: null, + createdAt: now, + updatedAt: now + }) + mockMainLoggerService.warn.mockClear() + + // Bulk read drops the row (fault isolation) and warns once with its id. + const entries = fileEntryService.findMany({ origin: 'external' }) + expect(entries).toEqual([]) + expect(mockMainLoggerService.warn).toHaveBeenCalledTimes(1) + expect(mockMainLoggerService.warn).toHaveBeenCalledWith( + expect.stringContaining('un-parseable'), + expect.objectContaining({ id }) + ) + }) + }) }) diff --git a/src/main/services/file/FileManager.ts b/src/main/services/file/FileManager.ts index 9311f48a3f5..afa339d4efa 100644 --- a/src/main/services/file/FileManager.ts +++ b/src/main/services/file/FileManager.ts @@ -149,6 +149,7 @@ import type { import { SafeExtSchema } from '@shared/types/file/common' import type { FileHandle } from '@shared/types/file/handle' import { FileHandleSchema } from '@shared/types/file/handle' +import { canonicalizeFilePath } from '@shared/utils/file/canonicalize' import mime from 'mime' import * as z from 'zod' @@ -824,7 +825,7 @@ export class FileManager extends BaseService implements IFileManager { } async findByExternalPath(path: FilePath): Promise { - return this.deps.fileEntryService.findByExternalPath(path) + return this.deps.fileEntryService.findByExternalPath(canonicalizeFilePath(path)) } async ensureExternalEntry(params: EnsureExternalEntryParams): Promise { @@ -914,19 +915,22 @@ export class FileManager extends BaseService implements IFileManager { async batchEnsureExternalEntries(items: EnsureExternalEntryParams[]): Promise { // Within-batch path duplicates resolve to the same entry per the public // contract; the second occurrence reuses the just-inserted row. The - // canonical-path memoization here ensures both items end up in - // `succeeded` even though only one DB insert happens — and each carries - // its own `sourceRef`, so the caller can still correlate every input. + // in-memory memo keys on the branded `externalPath` directly — no re-parse, + // trusting the already-validated `FilePath` param. Byte-identical inputs + // dedup here; any canonically-equal-but-byte-different pair still coalesces + // one level down (`ensureExternalEntry` canonicalizes and hits the DB + // upsert). Both items end up in `succeeded` even though only one DB insert + // happens — and each carries its own `sourceRef`, so the caller can still + // correlate every input. const seen = new Map() const succeeded: BatchCreateResult['succeeded'] = [] const failed: BatchCreateResult['failed'] = [] for (const params of items) { const sourceRef = params.externalPath try { - const canonical = FilePathSchema.parse(params.externalPath) - const cached = seen.get(canonical) + const cached = seen.get(params.externalPath) const entry = cached ?? (await this.ensureExternalEntry(params)) - if (!cached) seen.set(canonical, entry) + if (!cached) seen.set(params.externalPath, entry) succeeded.push({ id: entry.id, sourceRef }) } catch (err) { // Wire format only carries `.message`; preserve the stack via the diff --git a/src/main/services/file/internal/entry/create.ts b/src/main/services/file/internal/entry/create.ts index 33137eeff2c..0155f8dc0a9 100644 --- a/src/main/services/file/internal/entry/create.ts +++ b/src/main/services/file/internal/entry/create.ts @@ -17,6 +17,7 @@ import { loggerService } from '@logger' import { atomicWriteFile, copy as fsCopy, download, remove as fsRemove, stat as fsStat } from '@main/utils/file/fs' import type { FileEntry } from '@shared/data/types/file' import { type FilePath, FilePathSchema } from '@shared/types/file' +import { canonicalizeFilePath } from '@shared/utils/file/canonicalize' import mime from 'mime' import { v7 as uuidv7 } from 'uuid' @@ -164,16 +165,17 @@ export async function createInternal(deps: FileManagerDeps, params: CreateIntern /** * Ensure an entry exists for a user-provided absolute path. Pure upsert keyed - * by `params.externalPath`, which is already a canonical `FilePath` — the - * `FilePathSchema` brand at the IPC boundary is the sole canonicalization - * step, so this function derives every downstream value directly from it - * without re-canonicalizing. Path existence is verified via `fs.stat` before - * insert; ENOENT propagates. + * by the canonical form of `params.externalPath`: `FilePathSchema` at the IPC + * boundary validates shape only, so this function canonicalizes the input via + * `canonicalizeFilePath` and derives every downstream value (lookup, dedup, + * name/ext projection, persisted `externalPath`) from that `CanonicalFilePath`. + * Path existence is verified via `fs.stat` before insert; ENOENT propagates. */ export async function ensureExternal(deps: FileManagerDeps, params: EnsureExternalEntryParams): Promise { - const existing = deps.fileEntryService.findByExternalPath(params.externalPath) + const canonical = canonicalizeFilePath(params.externalPath) + const existing = deps.fileEntryService.findByExternalPath(canonical) if (existing) return existing - await fsStat(params.externalPath) + await fsStat(canonical) // Case-insensitive peer lookup is index-backed via the // `fe_external_path_lower_unique_idx` functional UNIQUE on `lower(externalPath)`. // The same index hard-rejects an INSERT that would collide with an existing @@ -194,12 +196,12 @@ export async function ensureExternal(deps: FileManagerDeps, params: EnsureExtern // subsequent INSERT would fail at the same boundary with a more // diagnosable stack, so wrapping in try/catch here only hides the real // error one stack frame earlier. - const peers = deps.fileEntryService.findCaseInsensitivePeers(params.externalPath) + const peers = deps.fileEntryService.findCaseInsensitivePeers(canonical) if (peers.length > 0) { - const reusable = await resolveCaseCollisionPeer(params.externalPath, peers) + const reusable = await resolveCaseCollisionPeer(canonical, peers) if (reusable) { logger.info('ensureExternal: reusing case-collision peer (fs.realpath confirmed same FS entry)', { - newPath: params.externalPath, + newPath: canonical, peerId: reusable.id, peerPath: (reusable as { externalPath: string }).externalPath }) @@ -214,7 +216,7 @@ export async function ensureExternal(deps: FileManagerDeps, params: EnsureExtern // uniqueness is what option (c) brings. throw new Error( `ensureExternal: case-collision with existing entries — fs.realpath confirms different FS entities. ` + - `New: ${params.externalPath}; conflicting peers: ${peers + `New: ${canonical}; conflicting peers: ${peers .map((p) => `${p.id}=${(p as { externalPath: string }).externalPath}`) .join(', ')}` ) @@ -225,20 +227,20 @@ export async function ensureExternal(deps: FileManagerDeps, params: EnsureExtern // architecture §3.3) is now enforced by the IPC type lacking a `name` // override field. Phase 2 consumers that want a different display name // must `rename` after `ensureExternalEntry` returns. - const name = defaultNameFromPath(params.externalPath) - const ext = extWithoutDot(params.externalPath) + const name = defaultNameFromPath(canonical) + const ext = extWithoutDot(canonical) const inserted = deps.fileEntryService.create({ origin: 'external', name, ext, - externalPath: params.externalPath + externalPath: canonical }) // Reverse-index hook: subsequent watcher / opportunistic ops events for - // `params.externalPath` should reach this entry id. The fs.stat above - // succeeded — record a fresh 'present' observation so any imminent UI - // query short-circuits the cold-stat path. - deps.danglingCache.addEntry(inserted.id, params.externalPath) - deps.danglingCache.onFsEvent(params.externalPath, 'present', 'ops') + // `canonical` should reach this entry id. The fs.stat above succeeded — + // record a fresh 'present' observation so any imminent UI query + // short-circuits the cold-stat path. + deps.danglingCache.addEntry(inserted.id, canonical) + deps.danglingCache.onFsEvent(canonical, 'present', 'ops') return inserted } diff --git a/src/main/services/file/internal/entry/rename.ts b/src/main/services/file/internal/entry/rename.ts index dbeaf2989b8..57e423c43fa 100644 --- a/src/main/services/file/internal/entry/rename.ts +++ b/src/main/services/file/internal/entry/rename.ts @@ -14,7 +14,7 @@ import { loggerService } from '@logger' import { exists, isSameFile, move as fsMove } from '@main/utils/file/fs' import type { FileEntry, FileEntryId } from '@shared/data/types/file' import { SafeNameSchema } from '@shared/data/types/file' -import { FilePathSchema } from '@shared/types/file' +import { canonicalizeFilePath } from '@shared/utils/file/canonicalize' import type { FileManagerDeps } from '../deps' @@ -43,7 +43,7 @@ export async function rename(deps: FileManagerDeps, id: FileEntryId, newName: st // canonical (written through `ensureExternalEntry`), so string equality // here is a reliable "same logical path" test. const oldPath = entry.externalPath - const target = FilePathSchema.parse(path.join(dir, `${newName}${ext}`)) + const target = canonicalizeFilePath(path.join(dir, `${newName}${ext}`)) // Defense in depth: `SafeNameSchema.parse(newName)` above already rejects // path separators and `..`, but a future regression in the schema (or any // canonicalization behaviour change) must not be able to relocate the diff --git a/src/shared/data/types/file/fileEntry.ts b/src/shared/data/types/file/fileEntry.ts index 90ccf59471e..7aac154f16c 100644 --- a/src/shared/data/types/file/fileEntry.ts +++ b/src/shared/data/types/file/fileEntry.ts @@ -98,7 +98,8 @@ * unmanaged `@main/utils/file/fs.remove(path)` separately. */ -import { FilePathSchema, SafeExtSchema } from '@shared/types/file/common' +import { type CanonicalFilePath, FilePathSchema, SafeExtSchema } from '@shared/types/file/common' +import { canonicalizeAbsolutePath } from '@shared/utils/file/canonicalize' import * as z from 'zod' import { SafeNameSchema, TimestampSchema } from './essential' @@ -209,13 +210,26 @@ export const ExternalEntrySchema = z.strictObject({ ...CommonEntryFields, origin: z.literal('external'), /** - * Absolute filesystem path to the user-provided file. `FilePathSchema` - * transforms the value through `canonicalizeAbsolutePath` (NFC + resolve + - * trailing-strip + null-byte reject) and brands it `FilePath`, so any value - * the BO exposes is provably canonical — no `as FilePath` casts at read - * sites (rename.ts, lifecycle.ts, danglingCache.ts, …). + * Absolute filesystem path to the user-provided file, as a + * `CanonicalFilePath`. Canonical form is guaranteed at WRITE time by + * `canonicalizeFilePath` (external-entry insert / rename); this field does + * NOT re-canonicalize on read. A stored value that is not already canonical + * is REJECTED at parse (surfaced via `rowToFileEntrySafe`'s warn-skip) — + * reads never silently rewrite the value. Rejecting rather than repairing + * keeps the lookup/dedup key stable: a re-canonicalization migration is the + * only sanctioned way to fix historically non-canonical rows. */ - externalPath: FilePathSchema + externalPath: FilePathSchema.refine((s) => { + // Reject a stored value that is not already canonical (no silent repair): + // canonicalizeAbsolutePath throws on structural failure, absorbed here. + try { + return s === canonicalizeAbsolutePath(s) + } catch { + return false + } + }, 'externalPath must be canonical (produced via canonicalizeFilePath) before persistence').transform( + (s): CanonicalFilePath => s as CanonicalFilePath + ) }) /** diff --git a/src/shared/types/file/__tests__/FilePathSchema.test.ts b/src/shared/types/file/__tests__/FilePathSchema.test.ts index d97edf94f78..df751e40c1d 100644 --- a/src/shared/types/file/__tests__/FilePathSchema.test.ts +++ b/src/shared/types/file/__tests__/FilePathSchema.test.ts @@ -3,35 +3,35 @@ import { describe, expect, it } from 'vitest' import { FilePathSchema } from '../common' describe('FilePathSchema', () => { - it('accepts a POSIX absolute path unchanged (already canonical)', () => { + // FilePathSchema validates absolute-path SHAPE only and does NOT canonicalize: + // `parse(x)` returns `x` byte-for-byte. Canonicalization (NFC + segment + // resolve + trailing-strip + drive-letter upcase) is a separate concern owned + // by `canonicalizeFilePath` / `canonicalizeAbsolutePath` — its behavior is + // pinned in `@shared/utils/file/__tests__/canonicalize.test.ts`. + + it('returns a POSIX absolute path unchanged', () => { expect(FilePathSchema.parse('/Users/me/doc.pdf')).toBe('/Users/me/doc.pdf') }) - it('accepts a Windows backslash absolute path unchanged', () => { + it('returns a Windows backslash absolute path unchanged', () => { expect(FilePathSchema.parse('C:\\Users\\me\\doc.pdf')).toBe('C:\\Users\\me\\doc.pdf') }) - it('accepts a Windows forward-slash absolute path and canonicalizes to backslash', () => { - expect(FilePathSchema.parse('C:/Users/me/doc.pdf')).toBe('C:\\Users\\me\\doc.pdf') - }) - - it('NFC-normalizes decomposed (NFD) input', () => { - const nfd = '/Users/me/cafe\u0301.txt' // "café" as e + U+0301 combining acute (NFD) - const nfc = '/Users/me/caf\u00e9.txt' // "café" with precomposed U+00E9 (NFC) - expect(FilePathSchema.parse(nfd)).toBe(nfc) + it('accepts a Windows forward-slash absolute path and returns it unchanged (no backslash conversion)', () => { + expect(FilePathSchema.parse('C:/Users/me/doc.pdf')).toBe('C:/Users/me/doc.pdf') }) - it('strips a trailing separator', () => { - expect(FilePathSchema.parse('/foo/bar/')).toBe('/foo/bar') + it('does NOT NFC-normalize decomposed (NFD) input — returns it unchanged', () => { + const nfd = '/Users/me/cafe\u0301.txt' // "cafe\u0301" as e + U+0301 combining acute (NFD) + expect(FilePathSchema.parse(nfd)).toBe(nfd) }) - it('resolves . and .. segments', () => { - expect(FilePathSchema.parse('/foo/./baz/../bar')).toBe('/foo/bar') + it('does NOT strip a trailing separator — returns it unchanged', () => { + expect(FilePathSchema.parse('/foo/bar/')).toBe('/foo/bar/') }) - it('is idempotent', () => { - const once = FilePathSchema.parse('/foo/../bar/') - expect(FilePathSchema.parse(once)).toBe(once) + it('does NOT resolve . and .. segments — returns them unchanged', () => { + expect(FilePathSchema.parse('/foo/./baz/../bar')).toBe('/foo/./baz/../bar') }) it('rejects a relative path', () => { diff --git a/src/shared/types/file/common.ts b/src/shared/types/file/common.ts index b6e037dde25..fba81da33b3 100644 --- a/src/shared/types/file/common.ts +++ b/src/shared/types/file/common.ts @@ -2,7 +2,6 @@ * General file module types — used across ops, FileManager, and IPC. */ -import { canonicalizeAbsolutePath } from '@shared/utils/file/canonicalize' import * as z from 'zod' // ─── File Type Classification ─── @@ -30,8 +29,15 @@ export type FileType = z.infer // ─── Content Source Types ─── /** - * Absolute filesystem path that has passed through `FilePathSchema`: - * NFC-normalized, segment-resolved, trailing-separator-stripped, no null bytes. + * Absolute filesystem path validated by `FilePathSchema`. Validates the path + * SHAPE only — absolute form, no null bytes — and does NOT canonicalize: + * `FilePathSchema.parse(x)` returns `x` unchanged (the path exactly as given). + * + * The canonical form of a path (NFC + segment-resolve + trailing-separator + * strip + drive-letter upcase) is a distinct `CanonicalFilePath` produced by + * `canonicalizeFilePath()` (`@shared/utils/file/canonicalize`); it is applied + * explicitly at the external-path persistence / lookup boundary, not on every + * `FilePath` parse. * * The `z.brand` is a phantom brand — zero runtime cost, dropped on IPC * serialization; receivers re-assert via `FilePathSchema.parse()` at the @@ -39,18 +45,34 @@ export type FileType = z.infer * - Production: `FilePathSchema.parse(raw)` / `.safeParse(raw)` * - Tests / fixtures: `'…' as FilePath` for readability * - * Accepts POSIX (`/…`) and Windows (`X:\…` or `X:/…`) absolute forms; the - * canonical output is always backslash on Windows. Rejects `file://` URLs. + * Accepts POSIX (`/…`) and Windows (`X:\…` or `X:/…`) absolute forms. + * Rejects `file://` URLs. */ export const FilePathSchema = z .string() .min(1) .refine((s) => !s.includes('\0'), 'must not contain null bytes') .refine((s) => s.startsWith('/') || /^[A-Za-z]:[/\\]/.test(s), 'must be an absolute filesystem path') - .transform((v) => canonicalizeAbsolutePath(v)) .brand<'FilePath'>() export type FilePath = z.infer + +/** + * A `FilePath` additionally proven to be in canonical form + * (`canonicalizeAbsolutePath`: NFC + segment-resolve + trailing-separator + * strip + drive-letter upcase). This is the form persisted in + * `file_entry.externalPath` and used as the dedup / lookup key. + * + * TS-only phantom brand with a STRING-LITERAL key (not a `unique symbol`): + * `FileEntry.externalPath` flows into preload's inferred `WindowApiType` + * aggregate, where a transitively-embedded `unique symbol` brand triggers + * TS2527/TS4023. The ONLY sanctioned producer is `canonicalizeFilePath()` + * in `@shared/utils/file/canonicalize`; production code obtains it from that + * factory (directly or transitively), never via a bare `as` cast. A + * `CanonicalFilePath` IS a `FilePath`, so it is accepted anywhere a + * `FilePath` is. + */ +export type CanonicalFilePath = FilePath & { readonly __canonical: 'CanonicalFilePath' } export type Base64String = `data:${string};base64,${string}` export type UrlString = `http://${string}` | `https://${string}` diff --git a/src/shared/types/file/index.ts b/src/shared/types/file/index.ts index 2f435fe7ea9..9346c86e03f 100644 --- a/src/shared/types/file/index.ts +++ b/src/shared/types/file/index.ts @@ -1,5 +1,6 @@ export { type Base64String, + type CanonicalFilePath, type DirectoryEntry, type DirectoryListOptions, FILE_TYPE, diff --git a/src/shared/utils/file/__tests__/canonicalize.test.ts b/src/shared/utils/file/__tests__/canonicalize.test.ts index 22b85c09a72..00cfccec908 100644 --- a/src/shared/utils/file/__tests__/canonicalize.test.ts +++ b/src/shared/utils/file/__tests__/canonicalize.test.ts @@ -4,8 +4,9 @@ * * For inputs that match the host platform, the result must equal what the * main-side `path.resolve` + NFC + trailing-strip pipeline produces; this - * keeps the canonicalize-on-write (main) and canonicalize-on-parse (schema) - * sides in lockstep. Cross-platform cases (Windows-shaped paths processed on + * keeps the canonicalize-on-write path (`canonicalizeFilePath`, applied at the + * external-path boundary) and the schema's `externalPath` canonical-equivalence + * refine in lockstep. Cross-platform cases (Windows-shaped paths processed on * POSIX hosts and vice versa) are pinned by handcrafted expectations because * `path.resolve` is host-aware and can't be used as the oracle there. */ @@ -14,7 +15,7 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' -import { canonicalizeAbsolutePath } from '../canonicalize' +import { canonicalizeAbsolutePath, canonicalizeFilePath } from '../canonicalize' function nodeCanonicalize(raw: string): string { let normalized = path.resolve(raw) @@ -79,3 +80,17 @@ describe('canonicalizeAbsolutePath — Windows', () => { expect(canonicalizeAbsolutePath('C:\\')).toBe('C:\\') }) }) + +describe('canonicalizeFilePath (branding factory)', () => { + // The sole sanctioned producer of the CanonicalFilePath brand. At runtime it + // is exactly canonicalizeAbsolutePath + a phantom (compile-time) brand. + it('returns the canonicalized string (same result as canonicalizeAbsolutePath)', () => { + expect(canonicalizeFilePath('/foo/./bar/../baz')).toBe('/foo/baz') + expect(canonicalizeFilePath('/foo/bar/')).toBe('/foo/bar') + }) + + it('throws on non-absolute / null-byte input (delegated to canonicalizeAbsolutePath)', () => { + expect(() => canonicalizeFilePath('foo/bar')).toThrow(/absolute/i) + expect(() => canonicalizeFilePath('/foo/\0bar')).toThrow(/null byte/i) + }) +}) diff --git a/src/shared/utils/file/canonicalize.ts b/src/shared/utils/file/canonicalize.ts index 4549eb416be..5df47399679 100644 --- a/src/shared/utils/file/canonicalize.ts +++ b/src/shared/utils/file/canonicalize.ts @@ -1,11 +1,13 @@ /** * Pure-JS canonicalization for absolute filesystem paths. * - * Lives in shared (no `node:*` imports) so `FilePathSchema` (`@shared/types/file/common`) - * can `.transform` its input through the same canonicalization rule on every - * parse. That transform is what gives the `FilePath` brand real runtime - * backing — any value that survives parsing IS canonical, not just typed as - * if it were. + * Lives in shared (no `node:*` imports). This module owns two things: the + * canonicalization algorithm (`canonicalizeAbsolutePath`) and the branding + * factory (`canonicalizeFilePath`). `FilePath` (`@shared/types/file/common`) + * is shape-validated only — it does NOT canonicalize on parse. Canonicalization + * is applied explicitly via `canonicalizeFilePath`, which produces the + * `CanonicalFilePath` sub-brand, at the external-path persistence / lookup + * boundary. * * ## Scope (this function's contract) * @@ -30,6 +32,8 @@ * discipline`. */ +import type { CanonicalFilePath } from '@shared/types/file/common' + export function canonicalizeAbsolutePath(raw: string): string { if (raw.includes('\0')) { throw new Error('canonicalizeAbsolutePath: input contains null byte') @@ -39,6 +43,19 @@ export function canonicalizeAbsolutePath(raw: string): string { return normalized.normalize('NFC') } +/** + * The sole sanctioned producer of `CanonicalFilePath`: run the pure-JS + * canonicalization and brand the result. Callers needing a canonical + * lookup/persistence key (external-entry write + `findByExternalPath`) go + * through here instead of `as`-casting into the brand. + * + * @throws if `input` is not absolute / contains a null byte (delegated to + * `canonicalizeAbsolutePath`). + */ +export function canonicalizeFilePath(input: string): CanonicalFilePath { + return canonicalizeAbsolutePath(input) as CanonicalFilePath +} + function canonicalizePosix(raw: string): string { if (!raw.startsWith('/')) { throw new Error('canonicalizeAbsolutePath: path must be absolute') From b2f614e12c063d05829f9c553e9923166de24e90 Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sun, 5 Jul 2026 08:10:11 +0800 Subject: [PATCH 08/22] fix(composer): isolate and guard non-absolute attachment path parse buildFilePartsForAttachments used Promise.all + FilePathSchema.parse, so one attachment with a non-absolute/legacy path (e.g. a `data:`- or `http:`-derived FileMetadata) threw and rejected the whole batch, dropping every attachment on the message. Switch to Promise.allSettled: keep the parts that build successfully, and log (loggerService.warn) and skip any attachment that fails instead of failing the batch. Separately, ChatComposer's edit-and-resend path called `await buildEditedMessageParts(draft)` outside the try/catch that wraps `forkAndResend`, so the same throw became an unhandled promise rejection and the resend silently no-op'd with no toast or log. Move the call inside the existing try/catch so a failure hits the same reporting path (logger.warn + toast) as other resend failures. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- .../file/__tests__/buildFileParts.test.ts | 25 +++++++++++++++++++ src/renderer/utils/file/buildFileParts.ts | 23 ++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/renderer/utils/file/__tests__/buildFileParts.test.ts b/src/renderer/utils/file/__tests__/buildFileParts.test.ts index 171fcf1b397..230a6fd15aa 100644 --- a/src/renderer/utils/file/__tests__/buildFileParts.test.ts +++ b/src/renderer/utils/file/__tests__/buildFileParts.test.ts @@ -1,5 +1,6 @@ import { FILE_TYPE } from '@renderer/types/file' import type { ComposerAttachment } from '@renderer/utils/message/composerAttachment' +import { mockRendererLoggerService } from '@test-mocks/RendererLoggerService' import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildFilePartsForAttachments } from '../buildFileParts' @@ -67,4 +68,28 @@ describe('buildFilePartsForAttachments', () => { expect(part.mediaType).toBe('application/pdf') expect(part.url).toBe('file:///p/fe-3.pdf') }) + + it('isolates a non-absolute path: skips the bad attachment, logs a warning, and still returns the rest', async () => { + const warnSpy = vi.spyOn(mockRendererLoggerService, 'warn').mockImplementation(() => {}) + + const goodAttachment = attachment() + const badAttachment = attachment({ path: 'not-absolute.png', name: 'bad.png' }) + + const parts = await buildFilePartsForAttachments([goodAttachment, badAttachment]) + + expect(parts).toHaveLength(1) + expect(parts[0]).toEqual({ + type: 'file', + url: 'file:///p/fe-1.png', + mediaType: 'image/png', + filename: 'image.png', + providerMetadata: { cherry: { fileEntryId: 'fe-1' } } + }) + expect(warnSpy).toHaveBeenCalledWith( + 'failed to build file part for attachment, skipping it', + expect.objectContaining({ path: 'not-absolute.png', name: 'bad.png' }) + ) + + warnSpy.mockRestore() + }) }) diff --git a/src/renderer/utils/file/buildFileParts.ts b/src/renderer/utils/file/buildFileParts.ts index c286dc3de43..a974598f101 100644 --- a/src/renderer/utils/file/buildFileParts.ts +++ b/src/renderer/utils/file/buildFileParts.ts @@ -11,20 +11,26 @@ * for the accessor + Zod. */ +import { loggerService } from '@logger' import type { ComposerAttachment } from '@renderer/utils/message/composerAttachment' import type { FileUIPart } from '@shared/data/types/message' import { withCherryMeta } from '@shared/data/types/uiParts' import { FilePathSchema } from '@shared/types/file' import { createFilePathHandle } from '@shared/utils/file/handle' +const logger = loggerService.withContext('buildFileParts') + /** * For each `ComposerAttachment` (with an absolute `path`), create a v2 internal * FileEntry (Cherry copies the bytes into its own storage) and return a * `FileUIPart` that carries the new `fileEntryId` plus a `file://` URL * pointing at the freshly-copied physical file. + * + * A single attachment failing (e.g. a legacy/non-absolute `path`) is isolated: + * it is logged and skipped rather than rejecting the whole batch. */ export async function buildFilePartsForAttachments(attachments: ComposerAttachment[]): Promise { - return Promise.all( + const results = await Promise.allSettled( attachments.map(async (attachment) => { const entry = await window.api.file.createInternalEntry({ source: 'path', @@ -41,4 +47,19 @@ export async function buildFilePartsForAttachments(attachments: ComposerAttachme return withCherryMeta(basePart, { fileEntryId: entry.id }) }) ) + + const parts: FileUIPart[] = [] + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + parts.push(result.value) + return + } + const attachment = attachments[index] + logger.warn('failed to build file part for attachment, skipping it', { + path: attachment.path, + name: attachment.name, + error: result.reason + }) + }) + return parts } From ada6f60d93ae3028f3a3aa376360d43ccd861568 Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sun, 5 Jul 2026 08:17:28 +0800 Subject: [PATCH 09/22] refactor(file): log renderer FilePath safeParse failures FilePathSchema.safeParse failures in the renderer were silently swallowed with a fallback (undefined URL / legacy entry-id open). Log them so legacy/non-absolute/relative paths are observable instead of failing invisibly: - paintingFileUrl.ts (getPaintingFileUrl): warn before returning undefined. - fileTokenPresentation.tsx (getFilePreviewUrl): warn before returning undefined. - useMessageLeafCapabilities.ts: getFileView warns before yielding previewUrl: undefined; fileMetadataToHandle's legacy entry-id fallback (empty catch) now logs at debug so it's distinguishable from a truly corrupted path. - ArtifactPane.tsx: the HTML-preview baseUrl safeParse runs on every render, so warn once per distinct offending path via a module-level Set (warnedArtifactHtmlBasePaths) instead of warning every render. No success-path behavior changed; only added logging around existing fallbacks. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- .../useMessageLeafCapabilities.test.tsx | 20 +++++++++++++++-- .../hooks/useMessageLeafCapabilities.ts | 10 +++++++++ .../components/chat/panes/ArtifactPane.tsx | 13 ++++++++++- .../tokenView/fileTokenPresentation.tsx | 8 ++++++- .../utils/__tests__/paintingFileUrl.test.ts | 22 +++++++++++++++++-- .../pages/paintings/utils/paintingFileUrl.ts | 8 ++++++- 6 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/chat/messages/hooks/__tests__/useMessageLeafCapabilities.test.tsx b/src/renderer/components/chat/messages/hooks/__tests__/useMessageLeafCapabilities.test.tsx index c07860e78dc..ac0f0adaf81 100644 --- a/src/renderer/components/chat/messages/hooks/__tests__/useMessageLeafCapabilities.test.tsx +++ b/src/renderer/components/chat/messages/hooks/__tests__/useMessageLeafCapabilities.test.tsx @@ -12,10 +12,18 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) -const { mockUseExternalApps, mockPreview, mockSafeOpen } = vi.hoisted(() => ({ +const { mockUseExternalApps, mockPreview, mockSafeOpen, mockLoggerWarn, mockLoggerDebug } = vi.hoisted(() => ({ mockUseExternalApps: vi.fn(() => ({ data: [] })), mockPreview: vi.fn(), - mockSafeOpen: vi.fn() + mockSafeOpen: vi.fn(), + mockLoggerWarn: vi.fn(), + mockLoggerDebug: vi.fn() +})) + +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ warn: mockLoggerWarn, debug: mockLoggerDebug }) + } })) vi.mock('@renderer/hooks/useAttachment', () => ({ @@ -147,6 +155,10 @@ describe('useMessageLeafCapabilities', () => { await result.current.openFile?.(file) expect(mockSafeOpen).toHaveBeenCalledWith({ kind: 'entry', entryId: '019606a0-0000-7000-8000-000000000001' }) + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'fileMetadataToHandle: falling back to entry id for non-absolute path', + expect.objectContaining({ path: 'relative/legacy.pdf' }) + ) }) it('projects file display data for shared attachment renderers', () => { @@ -200,6 +212,10 @@ describe('useMessageLeafCapabilities', () => { displayName: 'file.pdf', previewUrl: undefined }) + expect(mockLoggerWarn).toHaveBeenCalledWith( + 'getFileView: non-canonical/invalid attachment path', + expect.objectContaining({ path: 'relative/legacy.pdf' }) + ) }) it('keeps legacy pasted temp-file display behavior local to message attachments', () => { diff --git a/src/renderer/components/chat/messages/hooks/useMessageLeafCapabilities.ts b/src/renderer/components/chat/messages/hooks/useMessageLeafCapabilities.ts index 3749585c182..286da15346f 100644 --- a/src/renderer/components/chat/messages/hooks/useMessageLeafCapabilities.ts +++ b/src/renderer/components/chat/messages/hooks/useMessageLeafCapabilities.ts @@ -1,4 +1,5 @@ import { useQuery } from '@data/hooks/useDataApi' +import { loggerService } from '@logger' import type { MessageListActions, MessageListState } from '@renderer/components/chat/messages/types' import { useAttachment } from '@renderer/hooks/useAttachment' import { useExternalApps } from '@renderer/hooks/useExternalApps' @@ -20,6 +21,8 @@ import { useTranslation } from 'react-i18next' import { type MessagePlatformActions, useMessagePlatformActions } from './useMessagePlatformActions' +const logger = loggerService.withContext('useMessageLeafCapabilities') + type MessageLeafActions = Pick< MessageListActions, 'previewFile' | 'openFile' | 'subscribeToolProgress' | 'openExternalUrl' | 'openInExternalApp' @@ -58,6 +61,10 @@ function fileMetadataToHandle(file: FileMetadata): FileHandle { } catch { // Fall back to the entry id for legacy FileMetadata whose path is not an // absolute filesystem path. The IPC schema is still the authority. + logger.debug('fileMetadataToHandle: falling back to entry id for non-absolute path', { + fileId: file.id, + path: file.path + }) } } @@ -136,6 +143,9 @@ export function useMessageLeafCapabilities({ const getFileView = useCallback>( (file) => { const parsedPath = file.path ? FilePathSchema.safeParse(file.path) : undefined + if (parsedPath && !parsedPath.success) { + logger.warn('getFileView: non-canonical/invalid attachment path', { fileId: file.id, path: file.path }) + } return { displayName: formatMessageAttachmentFileName(file, t), previewUrl: parsedPath?.success ? toSafeFileUrl(parsedPath.data, file.ext || null) : undefined diff --git a/src/renderer/components/chat/panes/ArtifactPane.tsx b/src/renderer/components/chat/panes/ArtifactPane.tsx index 8719115c110..02965b9ea0f 100644 --- a/src/renderer/components/chat/panes/ArtifactPane.tsx +++ b/src/renderer/components/chat/panes/ArtifactPane.tsx @@ -106,6 +106,10 @@ type OfficePreviewPanelComponent = ComponentType let pdfPreviewPanelPromise: Promise | null = null let officePreviewPanelPromise: Promise | null = null +// `ArtifactFilePreview` re-derives `parsedHtmlBase` on every render; dedupe the +// warn per offending path so an open HTML preview doesn't flood the log. +const warnedArtifactHtmlBasePaths = new Set() + const loadPdfPreviewPanel = () => { pdfPreviewPanelPromise ??= import('@renderer/components/ArtifactPreview/pdf/PdfPreviewPanel') .then((module) => module.default) @@ -445,7 +449,14 @@ export function ArtifactFilePreview({ } if (isHtmlFile(filePath)) { - const parsedHtmlBase = FilePathSchema.safeParse(joinPath(workspacePath, filePath)) + const htmlBasePath = joinPath(workspacePath, filePath) + const parsedHtmlBase = FilePathSchema.safeParse(htmlBasePath) + if (!parsedHtmlBase.success && !warnedArtifactHtmlBasePaths.has(htmlBasePath)) { + warnedArtifactHtmlBasePaths.add(htmlBasePath) + logger.warn('ArtifactPane: non-absolute HTML base path, relative resources may not resolve', { + path: htmlBasePath + }) + } return ( ({ + warn: vi.fn() +})) + +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ warn: mocks.warn }) + } +})) import { getPaintingFileUrl } from '../paintingFileUrl' describe('getPaintingFileUrl', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + it('returns undefined when no physical path is available', () => { expect(getPaintingFileUrl({ path: '', ext: '.png' })).toBeUndefined() }) @@ -16,9 +30,13 @@ describe('getPaintingFileUrl', () => { ).toBe('file:///resolved%20output/paint%20result.png') }) - it('returns undefined without throwing when the path is not absolute', () => { + it('returns undefined without throwing when the path is not absolute, and logs a warning', () => { expect(() => getPaintingFileUrl({ path: 'relative/legacy.png', ext: '.png' })).not.toThrow() expect(getPaintingFileUrl({ path: 'relative/legacy.png', ext: '.png' })).toBeUndefined() + expect(mocks.warn).toHaveBeenCalledWith( + 'getPaintingFileUrl: non-canonical/invalid painting path', + expect.objectContaining({ path: 'relative/legacy.png' }) + ) }) it('keeps shared file-url safety behavior', () => { diff --git a/src/renderer/pages/paintings/utils/paintingFileUrl.ts b/src/renderer/pages/paintings/utils/paintingFileUrl.ts index 6910559538b..c0a935dc960 100644 --- a/src/renderer/pages/paintings/utils/paintingFileUrl.ts +++ b/src/renderer/pages/paintings/utils/paintingFileUrl.ts @@ -1,7 +1,10 @@ +import { loggerService } from '@logger' import type { FileMetadata } from '@renderer/types/file' import { FilePathSchema, type FileUrlString } from '@shared/types/file' import { toSafeFileUrl } from '@shared/utils/file' +const logger = loggerService.withContext('paintingFileUrl') + type PaintingFileUrlSource = Pick /** @@ -12,6 +15,9 @@ type PaintingFileUrlSource = Pick export function getPaintingFileUrl(file: PaintingFileUrlSource): FileUrlString | undefined { if (!file.path) return undefined const parsedPath = FilePathSchema.safeParse(file.path) - if (!parsedPath.success) return undefined + if (!parsedPath.success) { + logger.warn('getPaintingFileUrl: non-canonical/invalid painting path', { path: file.path }) + return undefined + } return toSafeFileUrl(parsedPath.data, file.ext || null) } From 71a3b69c88d4553ea71f363af5d947bcf8ce7bec Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sun, 5 Jul 2026 08:25:02 +0800 Subject: [PATCH 10/22] refactor(file): fix stale path-type docs and harden externalPath/pathStatus edges Doc fixes: drop the dead pathResolver.ts realpath/case cross-refs in ipc.ts and canonicalize.ts (that logic now lives solely in internal/entry/create.ts), rewrite the externalPath IPC comment to reflect that FilePath is shape-only and canonicalization happens in ensureExternalEntry via canonicalizeFilePath, correct the toFileInfo.ts comment (FileInfoSchema.parse yields the exact type, no `as FileInfo`, FilePath is a Zod brand not a template literal), document the pre-existing UNC-path limitation on FilePathSchema, and reword the filepath-brand/no-as-filepath eslint rule text to describe shape validation instead of canonicalization. Code: setExternalPathAndNameTx now rejects a null byte in externalPath as defense-in-depth against a forged `as CanonicalFilePath` cast, with a test restoring the "rejects unsafe externalPath BEFORE the SQL UPDATE" coverage. getPathStatus now safeParses the path before the try/catch so a non-absolute/relative/file:// input is classified as missing instead of inaccessible, with a matching test case. Signed-off-by: eurfelux Co-Authored-By: Claude Opus 4.8 (1M context) --- eslint.config.mjs | 4 ++-- src/main/data/services/FileEntryService.ts | 14 +++++++---- .../__tests__/FileEntryService.test.ts | 22 +++++++++++++++++ src/main/services/file/toFileInfo.ts | 7 +++--- .../file/watcher/__tests__/watcher.test.ts | 2 +- src/main/services/file/watcher/index.ts | 2 +- .../utils/file/__tests__/pathStatus.test.ts | 4 ++++ src/main/utils/file/pathStatus.ts | 12 +++++++++- src/shared/types/file/common.ts | 4 ++++ src/shared/types/file/info.ts | 2 +- src/shared/types/file/ipc.ts | 24 +++++++++---------- src/shared/utils/file/canonicalize.ts | 6 ++++- 12 files changed, 76 insertions(+), 27 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index b3d11568b91..4c3cd7f5da8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -237,12 +237,12 @@ export default defineConfig([ type: 'problem', docs: { description: - 'Disallow `as FilePath` casts. FilePath is a Zod brand asserting a canonicalized path; forging it bypasses the canonicalization guarantee. Construct via FilePathSchema.parse().', + 'Disallow `as FilePath` casts. FilePath is a Zod brand asserting shape validation (absolute path, no null bytes); forging it bypasses that validation. Construct via FilePathSchema.parse().', recommended: true }, messages: { noAsFilePath: - '`as FilePath` forges the brand and skips canonicalization. Build it with FilePathSchema.parse(value) instead. If this is a deliberate raw-path regime, move it under an exempted path or justify with an eslint-disable + reason.' + '`as FilePath` forges the brand, skipping FilePathSchema\'s absolute-path validation. Build it with FilePathSchema.parse(value) instead. If this is a deliberate raw-path regime, move it under an exempted path or justify with an eslint-disable + reason.' } }, create(context) { diff --git a/src/main/data/services/FileEntryService.ts b/src/main/data/services/FileEntryService.ts index 31297ec4cff..593b78cc9ee 100644 --- a/src/main/data/services/FileEntryService.ts +++ b/src/main/data/services/FileEntryService.ts @@ -611,11 +611,17 @@ class FileEntryServiceImpl implements FileEntryService { setExternalPathAndNameTx(tx: DbOrTx, id: FileEntryId, externalPath: CanonicalFilePath, name: string): FileEntry { // Same pre-SQL validation rationale as `update` above; an unsafe `name` // would corrupt the row past `rowToFileEntry` parse. `externalPath` needs - // no re-parse here: the `CanonicalFilePath` brand can only be produced by - // `canonicalizeFilePath()`, so callers already proved canonicalization at - // construction time — re-running it would just re-verify a fact the type - // system already guarantees. + // no full re-canonicalization here: the `CanonicalFilePath` brand can only + // be produced by `canonicalizeFilePath()`, so callers already proved + // canonicalization at construction time. The null-byte check below is + // cheap defense-in-depth against a forged `as CanonicalFilePath` cast + // (e.g. from the lint-exempt `watcher/**` / `tree/**` regimes) that would + // otherwise persist a poison value SQLite happily stores but that later + // makes the row unreadable once `canonicalizeAbsolutePath` throws on it. SafeNameSchema.parse(name) + if (externalPath.includes('\0')) { + throw new Error('setExternalPathAndNameTx: externalPath contains a null byte') + } const rows = tx .update(fileEntryTable) .set({ externalPath, name, updatedAt: Date.now() }) diff --git a/src/main/data/services/__tests__/FileEntryService.test.ts b/src/main/data/services/__tests__/FileEntryService.test.ts index 50fd35ec026..6e858ed26e5 100644 --- a/src/main/data/services/__tests__/FileEntryService.test.ts +++ b/src/main/data/services/__tests__/FileEntryService.test.ts @@ -1231,6 +1231,28 @@ describe('FileEntryService', () => { expect(raw?.externalPath).toBe('/Users/me/safe.txt') }) + it('rejects unsafe externalPath BEFORE the SQL UPDATE commits', async () => { + // Defense-in-depth guard: the `CanonicalFilePath` brand normally + // guarantees canonical-ness, but a forged `as CanonicalFilePath` cast + // (e.g. from the lint-exempt watcher/tree regimes) could smuggle a null + // byte past the type system. The null-byte check must reject it before + // the SQL UPDATE commits — raw SELECT proves the row stayed unchanged. + const entry = fileEntryService.create({ + origin: 'external', + name: 'safe', + ext: 'txt', + externalPath: '/Users/me/safe.txt' + }) + + expect(() => + fileEntryService.setExternalPathAndName(entry.id, '/Users/me/legit\0.txt' as CanonicalFilePath, 'legit') + ).toThrow() + + const [raw] = await dbh.db.select().from(fileEntryTable).where(eq(fileEntryTable.id, entry.id)) + expect(raw?.name).toBe('safe') + expect(raw?.externalPath).toBe('/Users/me/safe.txt') + }) + it('throws on fe_external_path_unique_idx conflict (race against a concurrent rename to the same path)', async () => { // Two external entries racing to claim the same canonical path: the // unique index rejects the second UPDATE with a SQLite constraint diff --git a/src/main/services/file/toFileInfo.ts b/src/main/services/file/toFileInfo.ts index ad37bea56cc..3c347e8e5d0 100644 --- a/src/main/services/file/toFileInfo.ts +++ b/src/main/services/file/toFileInfo.ts @@ -37,10 +37,9 @@ export async function toFileInfo(entry: FileEntry): Promise { const s = await fsStat(physicalPath) const ext = entry.ext const inferredMime = ext ? (mime.getType(ext) ?? 'application/octet-stream') : 'application/octet-stream' - // `FileInfoSchema.parse` rehydrates the `FileInfo` brand. Casting back to - // `FileInfo` lets the `path` field carry the `FilePath` template-literal - // type at the API surface (Zod can't express template literals); the - // runtime shape check is otherwise identical. + // `FileInfoSchema.parse` validates the shape and returns the exact `FileInfo` + // type (including the branded `FilePath` on `path`) — no `as FileInfo` cast + // needed. return FileInfoSchema.parse({ path: physicalPath, name: entry.name, diff --git a/src/main/services/file/watcher/__tests__/watcher.test.ts b/src/main/services/file/watcher/__tests__/watcher.test.ts index dc096c92bc3..c4c49711f21 100644 --- a/src/main/services/file/watcher/__tests__/watcher.test.ts +++ b/src/main/services/file/watcher/__tests__/watcher.test.ts @@ -201,7 +201,7 @@ describe('createDirectoryWatcher', () => { const canonicalPath = path.join(dir, nfc) as FilePath // DanglingCache's reverse index is populated by `ensureExternalEntry`, - // whose `externalPath` is already NFC-canonical via `FilePathSchema`. + // whose `externalPath` is already NFC-canonical via `canonicalizeFilePath`. // Mirror that here. danglingCache.addEntry('e-w-nfd' as FileEntryId, canonicalPath) diff --git a/src/main/services/file/watcher/index.ts b/src/main/services/file/watcher/index.ts index 358d1f85978..9fb828a2945 100644 --- a/src/main/services/file/watcher/index.ts +++ b/src/main/services/file/watcher/index.ts @@ -163,7 +163,7 @@ class DirectoryWatcherImpl implements DirectoryWatcher { * * The cache feed is keyed by canonical (NFC) path because `DanglingCache`'s * reverse index is populated by `ensureExternalEntry`, whose `externalPath` - * is already NFC-canonical via `FilePathSchema`. chokidar emits whatever the + * is already NFC-canonical via `canonicalizeFilePath`. chokidar emits whatever the * OS hands it; on macOS APFS that is NFD * for CJK / accented filenames migrated from HFS+ (or written by tools like * `rsync -E` that preserve the source encoding). Without normalizing here diff --git a/src/main/utils/file/__tests__/pathStatus.test.ts b/src/main/utils/file/__tests__/pathStatus.test.ts index 69082f39dc8..72a766d0671 100644 --- a/src/main/utils/file/__tests__/pathStatus.test.ts +++ b/src/main/utils/file/__tests__/pathStatus.test.ts @@ -38,4 +38,8 @@ describe('getPathStatus', () => { it('short-circuits a blank path to missing without touching the filesystem', async () => { await expect(getPathStatus(' ')).resolves.toEqual({ ok: false, reason: 'missing' }) }) + + it('reports missing for a non-absolute path (fails FilePathSchema, not inaccessible)', async () => { + await expect(getPathStatus('relative/path')).resolves.toEqual({ ok: false, reason: 'missing' }) + }) }) diff --git a/src/main/utils/file/pathStatus.ts b/src/main/utils/file/pathStatus.ts index 4cb98bbb13b..af064f26355 100644 --- a/src/main/utils/file/pathStatus.ts +++ b/src/main/utils/file/pathStatus.ts @@ -26,8 +26,18 @@ export async function getPathStatus(path: string): Promise { return { ok: false, reason: 'missing' } } + // Parse OUTSIDE the try: a non-absolute / relative / `~` / `file://` input + // fails `FilePathSchema` with a ZodError (no `.code`), which would otherwise + // fall into the `catch` below and be misclassified as `inaccessible`. Such + // an input can never resolve to a real path, so it gets the same `missing` + // verdict as an ENOENT. + const parsed = FilePathSchema.safeParse(path) + if (!parsed.success) { + return { ok: false, reason: 'missing' } + } + try { - const stats = await stat(FilePathSchema.parse(path)) + const stats = await stat(parsed.data) return { ok: true, kind: stats.isDirectory ? 'directory' : 'file' } } catch (error) { // `ENOENT` (nothing there) and `ENOTDIR` (a path component is a diff --git a/src/shared/types/file/common.ts b/src/shared/types/file/common.ts index fba81da33b3..f9acb202277 100644 --- a/src/shared/types/file/common.ts +++ b/src/shared/types/file/common.ts @@ -47,6 +47,10 @@ export type FileType = z.infer * * Accepts POSIX (`/…`) and Windows (`X:\…` or `X:/…`) absolute forms. * Rejects `file://` URLs. + * + * Known limitation (pre-existing): UNC paths (`\\server\share`) are rejected + * by the absolute-shape refine above — they match neither the POSIX `/` form + * nor the Windows `X:[/\\]` drive-letter form. */ export const FilePathSchema = z .string() diff --git a/src/shared/types/file/info.ts b/src/shared/types/file/info.ts index a013339df45..1321d6c0298 100644 --- a/src/shared/types/file/info.ts +++ b/src/shared/types/file/info.ts @@ -73,7 +73,7 @@ import { FilePathSchema, FileTypeSchema } from './common' export const FileInfoSchema = z .strictObject({ /** - * Absolute filesystem path. Canonicalized and branded `FilePath` by + * Absolute filesystem path. Shape-validated and branded `FilePath` by * `FilePathSchema` — anything that survives is safe to feed to `fs` APIs. */ path: FilePathSchema, diff --git a/src/shared/types/file/ipc.ts b/src/shared/types/file/ipc.ts index 2c5abd51960..0371bb12eb3 100644 --- a/src/shared/types/file/ipc.ts +++ b/src/shared/types/file/ipc.ts @@ -121,19 +121,19 @@ export type CreateInternalEntryIpcParams = * * ## Canonicalization * - * `externalPath` is typed as `FilePath`, which can only be produced by - * `FilePathSchema.parse()` / `.safeParse()` — the schema's `.transform` - * (NFC normalize, resolve segments, strip trailing separator, reject null - * bytes) runs in pure shared JS, so both renderer and main can construct one - * directly; production code MUST NEVER `as`-cast a raw `string` into `FilePath`. + * `externalPath` is typed as `FilePath` at this IPC boundary — shape-validated + * only (absolute form, no null bytes; see `FilePathSchema`), NOT canonicalized. + * Canonicalization into the stored `CanonicalFilePath` form (NFC normalize, + * resolve segments, strip trailing separator, drive-letter upcase) happens + * inside `ensureExternalEntry`, via `canonicalizeFilePath()` + * (`@shared/utils/file/canonicalize`) — not at IPC parse time. * - * What stays main-only is the **disambiguation** step beyond schema-level - * canonicalization: `ensureExternalEntry` additionally runs `fs.realpath` to - * resolve case-insensitive-filesystem collisions (see - * `src/main/services/file/utils/pathResolver.ts` and - * `internal/entry/create.ts`), which depends on main-only FS APIs. Skipping - * that disambiguation silently misses entries on case-insensitive filesystems - * and after symlink resolution — which is why it stays a main-side concern. + * What stays main-only is the **disambiguation** step beyond canonicalization: + * `ensureExternalEntry` additionally runs `fs.realpath` to resolve + * case-insensitive-filesystem collisions (see `internal/entry/create.ts`), + * which depends on main-only FS APIs. Skipping that disambiguation silently + * misses entries on case-insensitive filesystems and after symlink + * resolution — which is why it stays a main-side concern. */ export type EnsureExternalEntryIpcParams = { externalPath: FilePath diff --git a/src/shared/utils/file/canonicalize.ts b/src/shared/utils/file/canonicalize.ts index 5df47399679..9f4176695c0 100644 --- a/src/shared/utils/file/canonicalize.ts +++ b/src/shared/utils/file/canonicalize.ts @@ -76,7 +76,11 @@ function canonicalizePosix(raw: string): string { function canonicalizeWindows(raw: string): string { // Drive letter is uppercased so `C:\Foo` and `c:\Foo` canonicalize to the // same string at the byte layer — case folding the path itself is - // deliberately deferred (see pathResolver.ts JSDoc for the rationale). + // deliberately deferred: case-insensitive dedup is handled by the DB + // `lower(externalPath)` unique index plus an `fs.realpath` collision probe + // (see docs/references/file/file-manager-architecture.md §1.2 + // "Duplicate-entry detection on insert"), so no per-segment case-fold is + // needed here. const drive = raw.slice(0, 2).toUpperCase() const segments = raw.slice(3).split(/[/\\]/) const stack: string[] = [] From 17b50125110d30f410998c6f22031dde22b82f18 Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sun, 5 Jul 2026 16:19:27 +0800 Subject: [PATCH 11/22] docs(file): store externalPath byte-faithful; reject NFC-normalization dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite §1.2 to reflect the decided design: `externalPath` is persisted byte-faithful (only reachability-safe lexical cleanup, never Unicode- normalized), so the stored string always reaches the real file — including on normalization-sensitive filesystems (Linux) where an NFC-rewritten path would not exist on disk. Replace the "canonical invariant" + rule-evolution migration discipline with a "Rejected: Unicode (NFC) normalization" section recording the reject rationale (reachability-blocking, unverified premise, rare+cosmetic on macOS, YAGNI). Update the DanglingCache/watcher section: byte-faithful key, raw-byte event matching (no NFC), with check() as the correctness baseline. Implementation (drop NFC from canonicalizeAbsolutePath, watcher raw match, relax ensureExternal existence) follows in subsequent commits. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- .../file/file-manager-architecture.md | 45 ++++++++----------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/docs/references/file/file-manager-architecture.md b/docs/references/file/file-manager-architecture.md index 21362551ae5..45536bf7a24 100644 --- a/docs/references/file/file-manager-architecture.md +++ b/docs/references/file/file-manager-architecture.md @@ -55,9 +55,9 @@ CREATE UNIQUE INDEX fe_external_path_lower_unique_idx `fe_external_path_idx` (plain index on the raw `external_path`) backs byte-exact lookups (`findByExternalPath`, rename re-finds, path-resolution call sites). The functional index simultaneously serves the case-insensitive lookup path (`WHERE lower(externalPath) = lower(?)`) used by `findCaseInsensitivePeers` and enforces the uniqueness invariant — `ensureExternalEntry` MUST resolve case-collisions at the application layer before INSERT (see "Duplicate-entry detection on insert" below) because a DB-level rejection would otherwise surface as an opaque `SQLITE_CONSTRAINT`. Internal rows (`externalPath = NULL`) are exempt — SQLite treats multiple NULLs as distinct in a UNIQUE index. -**Canonical invariant of `externalPath`**: SQLite performs **byte-level** comparison on the raw `externalPath` column and cannot natively detect NFC ≡ NFD (Unicode). The functional index above handles case folding via `lower()` but does **not** apply Unicode normalization, so `externalPath` **must** be normalized via `FilePathSchema`'s transform (`canonicalizeAbsolutePath`) before persistence—this is an application-layer invariant, with `ensureExternalEntry` and `fileEntryService.findByExternalPath` as mandatory call sites. +**Stored form of `externalPath` — byte-faithful**: `externalPath` is persisted **exactly as the OS handed it to us**, with only reachability-safe lexical cleanup (null-byte reject, `./`/`../`/repeated-separator collapse, trailing-separator trim). It is **not** Unicode-normalized, so the stored string always reaches the real file on every filesystem — including normalization-*sensitive* ones (Linux ext4/btrfs) where an NFC-rewritten path would not exist on disk (see [Rejected: Unicode (NFC) normalization](#rejected-unicode-nfc-normalization-of-externalpath) below). Uniqueness is therefore **byte-exact plus case-fold** only: the `lower()` functional index catches identical and case-different paths; it deliberately does not fold Unicode forms. -**Compile-time enforcement via the `FilePath` brand**: `FilePathSchema.parse()` returns a branded `FilePath` (Zod brand, zero runtime cost; see `src/shared/types/file/common.ts`). Every DB read/write surface that filters by `externalPath` — today `findByExternalPath`, and any future DataApi endpoint or repository method — MUST accept this type, not a plain `string`. The type system then guarantees callers routed their input through the normalization transform, eliminating the "forgot to canonicalize" class of bug that would silently miss all matches. +**Compile-time enforcement via the `FilePath` brand**: `FilePathSchema.parse()` returns a branded `FilePath` (Zod brand, zero runtime cost; see `src/shared/types/file/common.ts`), and the external-entry lookup/write surfaces (`findByExternalPath`, `setExternalPathAndName`) accept the narrower `CanonicalFilePath` produced by the `canonicalizeFilePath()` factory. Every DB read/write surface that filters by `externalPath` MUST accept the branded type, not a plain `string`, so a caller cannot pass an unvalidated raw path and silently miss all matches. (`FilePathSchema` only *validates the absolute-path shape* — it does not mutate the value; see [FilePath vs CanonicalFilePath](../../../src/shared/types/file/common.ts).) | Source | Natively canonical | Relies on normalization to disambiguate | |---|---|---| @@ -67,42 +67,33 @@ CREATE UNIQUE INDEX fe_external_path_lower_unique_idx | External URL scheme / shell integration | ❌ | Same as above | | v1 migration (inherits Dexie stored values) | ❌ (inherits legacy value quality) | Canonicalize once during migration | -**Normalization scope** (synchronous, no FS IO): +**Normalization scope** (synchronous, no FS IO) — reachability-safe lexical cleanup only: - Null-byte rejection — `raw.includes('\0')` → throw, so poisoned paths never reach DB persistence (reject at the earliest boundary, not at use-time inside `resolvePhysicalPath`) - `path.resolve(raw)` → absolutize + eliminate `./` `../` -- `.normalize('NFC')` → Unicode normalization (closes the NFD/NFC window for macOS CJK) - Trailing separator trimming +- Windows: drive-letter case folding (`c:\` → `C:\`) + +Every step above is a filesystem no-op for *which file the path reaches* — the cleaned string still resolves to the same on-disk entry on every platform. **Unicode (NFC) normalization is deliberately NOT a step here** — see [Rejected](#rejected-unicode-nfc-normalization-of-externalpath) below. **Intentionally omitted** (deferred until concrete user feedback warrants the cost): -- `fs.realpath` as a step *inside* `canonicalizeAbsolutePath` itself (would require async FS IO at every canonicalization call site and a file-existence precondition). `fs.realpath` IS used on the `ensureExternalEntry` collision path described below — that is a per-collision probe, not a per-canonicalize step. +- `fs.realpath` as a step *inside* `canonicalizeAbsolutePath` itself (would require async FS IO at every call site and a file-existence precondition). `fs.realpath` IS used on the `ensureExternalEntry` collision path described below — that is a per-collision probe, not a per-canonicalize step. - Symlink target merging at canonicalize time - Windows 8.3 short-name resolution See the JSDoc for `canonicalizeAbsolutePath` in `src/shared/utils/file/canonicalize.ts` for the detailed contract. -#### Rule evolution discipline - -Because the canonical form is **application-layer logic**, not DB schema, any change to `canonicalizeAbsolutePath`'s normalization steps desynchronizes historical rows (written under the old rule) from new queries (running under the new rule). This produces a silent failure mode: byte-compare misses, the user sees "my file is in the library but the app says it isn't", and `ensureExternalEntry` inserts a duplicate. - -**Rule**: modifying `canonicalizeAbsolutePath` ≡ ship a paired Drizzle migration that re-canonicalizes every existing `file_entry` row with `origin='external'` in the **same PR**. No exceptions — even if the new rule is claimed "strictly more permissive", the byte-compare will still miss. - -When a rule change additionally collapses previously-distinct strings to the same canonical form (e.g. adding `fs.realpath` merges APFS case-insensitive duplicates), the migration MUST also merge the colliding rows. The rules below are prescriptive; follow them exactly rather than improvising per-migration. +#### Rejected: Unicode (NFC) normalization of `externalPath` -**Winner selection when merging rows**: +An earlier design NFC-normalized `externalPath` (inside `canonicalizeAbsolutePath`) before persistence, so a file named with a decomposable character (`é` = `e` + combining acute vs the precomposed `é`) would dedup to one row on macOS. **This is deliberately rejected.** `externalPath` is stored byte-faithful (see "Stored form" above) and is never Unicode-normalized. -1. Oldest `createdAt` wins (preserves user-visible history — a 3-year-old entry's creation timestamp is more valuable than a 3-day-old one's). -2. Tiebreaker: highest ref count (keeps the entry that more of the user's data already points at). -3. Final tiebreaker: smallest `id` by lexicographic order (deterministic, no FS-state dependency). +1. **Reachability (blocking).** On a normalization-*sensitive* filesystem (Linux ext4/btrfs, some SMB/NFS mounts) the directory entry stores exact bytes. NFC-normalizing an NFD-named file's path yields a string that does **not** exist on disk, so `ensureExternalEntry`'s `fs.stat`, the copy source, and `resolvePhysicalPath` all `ENOENT` — the file becomes unimportable and unreachable. Breaking Linux functionality to dedup on macOS is not an acceptable trade. +2. **Unverified premise.** The "most common duplicate trigger for CJK users" justification was AI-authored in a comment; this v2 code has never shipped, and there is no observed evidence that this app's path sources (`showOpenDialog`, drag-drop, `fs.readdir`) ever surface the same file in divergent normalization forms. +3. **Rare and cosmetic even on macOS.** A duplicate row needs a conjunction: the same file added more than once, through two ingestion routes that *disagree* on normalization, with a decomposable non-ASCII name, stored on disk in a divergent form. Even then APFS is normalization-insensitive, so both rows resolve to the *same* physical file — the harm is one extra, user-deletable list entry, not data loss or breakage. And the `fs.realpath` collision probe below does not catch it (NFD vs NFC are not `lower()` case-peers), so NFC-in-storage was the *sole* mechanism folding it — bought at the cost of item 1. +4. **YAGNI.** Storage-time normalization plus its rule-evolution migration discipline is real, permanent complexity for an unverified, rare, cosmetic condition. -**Losers' dependents** (executed in the same Drizzle transaction as the merge): +If real NFD/NFC duplicates are ever *observed*, the correct fix is an FS-aware `fs.realpath` fold at the collision probe (run only when the file exists), **never** re-introducing Unicode normalization into `canonicalizeAbsolutePath` or the stored form. -- Association rows with `fileEntryId = loser.id` → update to `winner.id`. No deduplication inside each table's `UNIQUE(fileEntryId, sourceId, role)` constraint is expected because each `(sourceId, role)` pair originally referenced only one entry; if violations occur, the update conflicts and the migration fails loudly (do not silently `ON CONFLICT DO NOTHING` — investigate). -- `file_entry.id = loser.id` → delete. -- Any downstream consumer of `loser.id` (future `file_upload.fileEntryId`, business-service caches keyed by entryId) MUST be enumerated and updated in the same migration. If you add a new table that references `file_entry.id`, the canonicalization migration procedure expands — document the expansion alongside the table's schema. - -**Atomicity**: the entire re-canonicalize + merge operation runs in one Drizzle migration transaction. On failure the DB rolls back to the pre-migration state and the next startup re-attempts; partial progress is not possible. - -**Renderer-side cache invalidation**: after the migration runs, some React Query caches keyed by the loser's `id` may be stale. Because migrations execute before the renderer boots, this is self-healing on the first query — no special coordination required. +> **Residual normalization discipline.** What remains in `canonicalizeAbsolutePath` is the reachability-safe lexical cleanup listed under "Normalization scope". It does not depend on filesystem semantics, so it does not carry the "changing the rule desyncs historical rows, requiring a paired re-canonicalize migration" hazard that the rejected NFC step did. (v2 has no shipped data to migrate regardless — schemas and rows are pre-release throwaway.) #### Duplicate-entry detection on insert @@ -684,7 +675,7 @@ Query: `WHERE deletedAt < now() - retentionMs` → batch permanentDelete. | unlink fails on permanentDelete internal (file already missing, permission issue) | Log warn; the DB row is already gone, so the failure surfaces only as an orphan blob that the next user-triggered orphan sweep will reclaim | | permanentDelete on external | DB-only by design; the user's file at `externalPath` is never touched — Cherry owns only the reference | | `ensureExternalEntry(path)` when an entry for the same path already exists | Entry point first calls `FilePathSchema.parse(raw)`; upsert returns the existing row. External entries cannot be trashed, so there is no "restore" branch. | -| **Two entries for the same file due to case / NFC differences** (macOS APFS, Windows NTFS, or NFD ↔ NFC input) | NFC closed by `FilePathSchema`'s canonicalization; case-collision rejected at INSERT by the DB functional unique index plus the `fs.realpath`-based reuse-or-throw decision in `ensureExternalEntry` (see §1.2 "Duplicate-entry detection on insert"). | +| **Two entries for the same file** | **Case** differences (macOS APFS, Windows NTFS): rejected at INSERT by the DB `lower()` functional unique index plus the `fs.realpath`-based reuse-or-throw probe in `ensureExternalEntry` (see §1.2 "Duplicate-entry detection on insert"). **Unicode (NFD ↔ NFC)** differences: deliberately **not** deduped — `externalPath` is stored byte-faithful, and the rare, cosmetic macOS case is accepted rather than break reachability on Linux (see §1.2 "Rejected: Unicode (NFC) normalization of `externalPath`"). | | External file at original path externally replaced with a different file | Cherry does not check content consistency (best-effort). `name` / `ext` on the row are derived from `externalPath` and do not change; `size` is always served live by `getMetadata`. DanglingCache flips to `'present'` on the next stat, so the UI just renders the new file under the existing reference. | | A trashed entry is permanently externally deleted and then restored | Appears dangling (DanglingCache returns missing on next check), UI shows failed style | | External write with permission error / disk full on target path | Throw without polluting DB; caller decides retry or user notification | @@ -1208,7 +1199,9 @@ Business modules **need not be directly aware of DanglingCache**. All watchers m - `unlink` → cache marks `missing` - `change` → cache untouched (file is still present; mtime drift is not tracked here) -The cache feed is keyed by canonical (NFC) form so it lines up with the reverse index populated by `ensureExternalEntry`; the path forwarded to subscribers is the raw OS form chokidar saw, so a subscriber that opens the file with that string stays coherent with what the FS actually has. +Because `externalPath` is stored **byte-faithful** (see §1.2 "Stored form"), the reverse index is keyed by that exact stored form and the watcher matches chokidar events by **raw byte equality** — no NFC normalization on either leg. (The NFC step that used to sit in the watcher existed only to bridge to the old NFC-canonical keys; it is removed together with them — see §1.2 "Rejected: Unicode (NFC) normalization".) The path forwarded to subscribers is likewise the raw OS form chokidar saw. + +`check()` is the correctness baseline: it stats `entry.externalPath` (byte-faithful) directly, so an entry's dangling state is always *eventually* correct regardless of whether any watcher event matched — the watcher leg is an eager-update latency optimization on top of it, never a correctness dependency. On Linux the raw event byte-matches the stored key by construction; on macOS/Windows it matches when the path source and chokidar agree on Unicode form. If they ever diverge (unverified — same open question as §1.2 "Rejected"), the only effect is that a watcher event misses and the badge stays stale until the next `check()` — benign and self-healing. The platform-conditional fold that could bridge such a divergence, *if ever observed*, belongs on the reverse-index comparison key (`process.platform`-aware: NFC on darwin/win32, identity on linux) — never on the stored form. **Note**: watcher rename events **do not auto-update an external entry's externalPath**—Cherry does not track external rename. After a rename, the original entry goes dangling; the user must re-@ to establish a new reference. From b70bcbf12589b73cfcf0aa8f104bdbdd79f43b92 Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sun, 5 Jul 2026 16:39:37 +0800 Subject: [PATCH 12/22] fix(file): store externalPath byte-faithful (drop NFC normalization) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonicalization no longer Unicode-NFC-normalizes: `canonicalizeAbsolutePath` now returns the byte-faithful, lexically-resolved form (segment resolve, trailing-strip, Windows drive-upcase) exactly as the OS handed it. An NFC-rewritten path does not exist on disk on normalization-sensitive filesystems (Linux ext4/btrfs), so an NFC-canonical `externalPath` would `fs.stat`/copy `ENOENT` and leave the file unreachable; storing it byte-faithful keeps it reachable on every filesystem. Uniqueness stays byte-exact plus case-fold only. The DirectoryWatcher's DanglingCache reverse index is now keyed byte-faithful, so `handle()` matches chokidar events by raw byte equality — the NFC-normalize bridge that existed only to reach the old NFC-canonical keys is removed together with them. `check()` (which stats the byte-faithful stored path directly) is the correctness baseline, so a missed watcher match only delays a badge update until the next check — benign and self-healing. `CanonicalFilePath` / `canonicalizeFilePath` keep their names; "canonical" now means the lexical, byte-faithful resolved form, explicitly NOT Unicode-normalized. Tests whose premise baked in NFC folding (canonicalize, FileEntryService R4, watcher, create derivation, rename no-op) are inverted to assert byte-faithful behavior. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- .../__tests__/FileEntryService.test.ts | 54 ++++++++++---- .../__tests__/FileManager.integration.test.ts | 4 +- .../internal/entry/__tests__/create.test.ts | 71 ++++++++----------- .../internal/entry/__tests__/rename.test.ts | 28 ++++---- .../services/file/internal/entry/rename.ts | 9 +-- .../file/watcher/__tests__/watcher.test.ts | 32 ++++----- src/main/services/file/watcher/index.ts | 29 ++++---- src/shared/data/types/file/fileEntry.ts | 28 +++++--- .../file/__tests__/FilePathSchema.test.ts | 9 +-- src/shared/types/file/common.ts | 19 ++--- src/shared/types/file/ipc.ts | 9 +-- .../utils/file/__tests__/canonicalize.test.ts | 29 +++++--- src/shared/utils/file/canonicalize.ts | 32 ++++++--- 13 files changed, 202 insertions(+), 151 deletions(-) diff --git a/src/main/data/services/__tests__/FileEntryService.test.ts b/src/main/data/services/__tests__/FileEntryService.test.ts index 6e858ed26e5..d55088eb78e 100644 --- a/src/main/data/services/__tests__/FileEntryService.test.ts +++ b/src/main/data/services/__tests__/FileEntryService.test.ts @@ -1622,28 +1622,56 @@ describe('FileEntryService', () => { }) }) - describe('non-canonical externalPath rejection (read-time contract)', () => { - it('warn-skips an external row whose stored externalPath is not canonical (NFD form)', async () => { - // Non-canonical externalPath rows are rejected on read — no silent repair; - // lookup-by-path requires a re-canonicalization migration. externalPath is - // guaranteed canonical only at WRITE time (via canonicalizeFilePath in - // ensureExternal / rename); a row inserted directly with a non-canonical - // value (bypassing the service write path) fails the schema's - // canonical-equivalence refine and is warn-skipped by rowToFileEntrySafe - // rather than returned or silently rewritten. + describe('externalPath byte-faithful acceptance + lexical rejection (read-time contract)', () => { + it('accepts an external row whose stored externalPath carries NFD Unicode (byte-faithful)', async () => { + // externalPath is stored byte-faithful — canonicalizeAbsolutePath does NOT + // Unicode-normalize, so an NFD path IS already in canonical form and reads + // back cleanly. (NFC-folding here would break reachability on + // normalization-sensitive filesystems like Linux ext4.) ASCII \u escapes + // keep the literal stable — raw combining marks get re-normalized by tooling. const id = '019606a0-0000-7000-8000-00000000ab01' as FileEntryId const now = Date.now() // "café.pdf" written in NFD: base 'e' + combining acute accent (U+0301). - // canonicalizeAbsolutePath NFC-folds this to a single U+00E9, so the stored - // bytes differ from their canonical form. ASCII \u escapes keep the literal - // stable — raw combining marks get silently re-normalized by tooling. - const nonCanonicalPath = '/Users/me/cafe\u0301.pdf' + const nfdPath = '/Users/me/cafe\u0301.pdf' await dbh.db.insert(fileEntryTable).values({ id, origin: 'external', name: 'cafe', ext: 'pdf', size: null, + externalPath: nfdPath, + deletedAt: null, + createdAt: now, + updatedAt: now + }) + mockMainLoggerService.warn.mockClear() + + // Bulk read returns the row unchanged — no warn, no silent rewrite. + const entries = fileEntryService.findMany({ origin: 'external' }) + expect(entries.map((e) => e.id)).toEqual([id]) + expect(entries[0].origin).toBe('external') + if (entries[0].origin === 'external') { + expect(entries[0].externalPath).toBe(nfdPath) // byte-faithful, still NFD + } + expect(mockMainLoggerService.warn).not.toHaveBeenCalled() + }) + + it('warn-skips an external row whose stored externalPath is not in lexical canonical form (unresolved `..`)', async () => { + // The byte-faithful refine still rejects a stored value that is not in the + // lexically-resolved form `canonicalizeFilePath` produces — no silent + // repair; lookup-by-path requires a re-canonicalization migration. A row + // carrying an unresolved `/../` segment (canonicalizeAbsolutePath collapses + // it) fails the schema's canonical-equivalence refine and is warn-skipped + // by rowToFileEntrySafe rather than returned or silently rewritten. + const id = '019606a0-0000-7000-8000-00000000ab02' as FileEntryId + const now = Date.now() + const nonCanonicalPath = '/Users/me/../me/DOC.pdf' // canonicalizes to /Users/me/DOC.pdf + await dbh.db.insert(fileEntryTable).values({ + id, + origin: 'external', + name: 'DOC', + ext: 'pdf', + size: null, externalPath: nonCanonicalPath, deletedAt: null, createdAt: now, diff --git a/src/main/services/file/__tests__/FileManager.integration.test.ts b/src/main/services/file/__tests__/FileManager.integration.test.ts index 4303d2c94f8..c92f4c5d163 100644 --- a/src/main/services/file/__tests__/FileManager.integration.test.ts +++ b/src/main/services/file/__tests__/FileManager.integration.test.ts @@ -125,7 +125,9 @@ describe('FileManager (integration)', () => { const found = await fm.findByExternalPath(FilePathSchema.parse(`${file}/`)) // trailing slash → canonicalize strips expect(found?.id).toBe(id) - // NFC re-normalization survives a synthesized NFD form + // Byte-faithful lookup: canonicalization does NOT Unicode-normalize, so an + // NFD synthesis of this ASCII path is byte-identical and matches the stored + // (byte-faithful) row exactly. const nfdFile = file.normalize('NFD') const foundNfc = await fm.findByExternalPath(FilePathSchema.parse(nfdFile)) expect(foundNfc?.id).toBe(id) diff --git a/src/main/services/file/internal/entry/__tests__/create.test.ts b/src/main/services/file/internal/entry/__tests__/create.test.ts index db9a4fe1ef7..816a07e6530 100644 --- a/src/main/services/file/internal/entry/__tests__/create.test.ts +++ b/src/main/services/file/internal/entry/__tests__/create.test.ts @@ -305,49 +305,36 @@ describe('internal/entry/create.createInternal', () => { ) }) - describe('ensureExternal canonical derivation', () => { - // Skip on linux: ext4 stores filenames as opaque bytes (no NFC/NFD - // equivalence), so a file written under an NFD name is genuinely a - // different FS entry from the NFC form — statting the canonical ENOENTs - // at the FS layer before the derivation invariant is exercised. The bug - // this guards is an APFS / NTFS concern (silent NFC-vs-NFD divergence - // between raw drag-drop input and `canonical`), which the macOS / Windows - // runners do exercise. - it.skipIf(process.platform === 'linux')( - 'derives name/ext from the canonical path, not the raw input (NFD → NFC byte equivalence)', - async () => { - // Regression guard: previously `name = params.name ?? defaultNameFromPath(params.externalPath)` - // and `ext = extWithoutDot(params.externalPath)` derived from the raw - // input. On macOS APFS the raw input can arrive in NFD form while - // the canonical form is NFC — persisting NFD-encoded name/ext alongside an - // NFC externalPath silently breaks `path.basename(canonical) === entry.name` - // equality checks. The fix derives every field from the canonical path. - // - // Canonicalization now happens once, at the `FilePathSchema.parse()` boundary - // (IPC schema in production), not inside `ensureExternal` itself — so this test - // builds `externalPath` via `FilePathSchema.parse(file)` to model exactly what a - // real caller hands to `ensureExternal`: an already-canonical `FilePath`. - // - // ASCII \u escapes (not raw accented literals) so formatter/editor tooling - // cannot silently re-normalize the NFD form and turn this into a tautology. - const nfdName = 'qu\u0065\u0301' // q, u, e, combining acute -> NFD - const nfcName = 'qu\u00E9' // q, u, e-precomposed -> NFC - expect(nfdName).not.toBe(nfcName) // byte-distinct strings - expect(nfdName.normalize('NFC')).toBe(nfcName) + describe('ensureExternal byte-faithful derivation', () => { + // `externalPath` is stored byte-faithful — `canonicalizeFilePath` does NOT + // Unicode-normalize, so an NFD-named file keeps its NFD bytes end to end: + // the stored path reaches the real file on every filesystem (including + // Linux ext4, where an NFC-rewritten path would ENOENT), and `name`/`ext` + // are derived from that byte-faithful path, not folded to NFC. Runs on all + // platforms: the byte-faithful path matches the on-disk bytes everywhere. + it('derives name/ext from the byte-faithful canonical path (NFD stays NFD, no NFC fold)', async () => { + // ASCII \u escapes (not raw accented literals) so formatter/editor tooling + // cannot silently re-normalize the NFD form and turn this into a tautology. + const nfdName = 'qu\u0065\u0301' // q, u, e, combining acute -> NFD + const nfcName = 'qu\u00E9' // q, u, e-precomposed -> NFC + expect(nfdName).not.toBe(nfcName) // byte-distinct strings + expect(nfdName.normalize('NFC')).toBe(nfcName) - const file = path.join(tmp, `${nfdName}.txt`) - await writeFile(file, 'x') - const entry = await ensureExternal(deps, { externalPath: FilePathSchema.parse(file) }) + const file = path.join(tmp, `${nfdName}.txt`) + await writeFile(file, 'x') + const entry = await ensureExternal(deps, { externalPath: FilePathSchema.parse(file) }) - if (entry.origin !== 'external') throw new Error('expected external entry') - // The stored externalPath is NFC (FilePathSchema applies .normalize('NFC')). - const canonical = entry.externalPath - expect(canonical.normalize('NFC')).toBe(canonical) - // name must derive from the canonical (NFC) basename, not the raw NFD input. - expect(entry.name).toBe(nfcName) - // Round-trip equality through path.basename now holds. - expect(path.basename(canonical, '.txt')).toBe(entry.name) - } - ) + if (entry.origin !== 'external') throw new Error('expected external entry') + // The stored externalPath is byte-faithful — the exact NFD bytes we passed, + // NOT folded to NFC. + const canonical = entry.externalPath + expect(canonical).toBe(file) + expect(canonical).not.toBe(file.normalize('NFC')) + // name derives from the byte-faithful (NFD) basename, not an NFC fold. + expect(entry.name).toBe(nfdName) + expect(entry.name).not.toBe(nfcName) + // Round-trip equality through path.basename holds byte-for-byte. + expect(path.basename(canonical, '.txt')).toBe(entry.name) + }) }) }) diff --git a/src/main/services/file/internal/entry/__tests__/rename.test.ts b/src/main/services/file/internal/entry/__tests__/rename.test.ts index 8965b88cecd..0f2865a1c9b 100644 --- a/src/main/services/file/internal/entry/__tests__/rename.test.ts +++ b/src/main/services/file/internal/entry/__tests__/rename.test.ts @@ -119,21 +119,22 @@ describe('internal/entry/rename', () => { expect(await readFile(collision, 'utf-8')).toBe('B') }) - it('treats NFC/NFD-equivalent names as a no-op (no fs.rename, no DB write)', async () => { - // macOS HFS+/APFS surface filenames in NFD; renderer input is NFC. - // path.join produces a string whose codepoints differ from the stored - // (NFC) externalPath even though they refer to the same logical file. - // Canonicalization on both sides must collapse this difference. - // Explicit escape construction — relying on source-literal `é` is - // unreliable because editors/formatters may NFC-normalize on save. - const nfcName = 'qu\u00e9' // 'qué' — single codepoint U+00E9 - const nfdName = 'qu\u0065\u0301' // 'qué' — e + combining acute + it('treats NFC and NFD spellings as distinct byte-faithful targets (renames, not a no-op)', async () => { + // externalPath is stored byte-faithful (no NFC), so an NFC-stored entry + // renamed to the NFD spelling of the same display name is a REAL rename to + // a byte-distinct target, not a short-circuited no-op. Explicit escape + // construction — relying on source-literal `é` is unreliable because + // editors/formatters may NFC-normalize on save. + const nfcName = 'qu\u00e9' // 'qué' — single codepoint U+00E9 (NFC) + const nfdName = 'qu\u0065\u0301' // 'qué' — e + combining acute (NFD) expect(nfcName).not.toBe(nfdName) // byte-distinct strings expect(nfcName.normalize('NFC')).toBe(nfdName.normalize('NFC')) const filePath = path.join(tmp, `${nfcName}.txt`) await writeFile(filePath, 'x') const entry = await ensureExternal(deps, { externalPath: filePath as FilePath }) + if (entry.origin !== 'external') throw new Error('expected external entry') + expect(entry.externalPath).toBe(filePath) // byte-faithful NFC, no fold // Spy on the file module's `move` wrapper, not `node:fs/promises.rename`: // the latter is a Node native ESM namespace member and Vitest cannot @@ -144,15 +145,18 @@ describe('internal/entry/rename', () => { const fsModule = await import('@main/utils/file/fs') const moveSpy = vi.spyOn(fsModule, 'move') - // Re-rename to the NFD form — same logical name, different codepoints. + // Rename to the NFD spelling — same logical name, byte-distinct target. const result = await rename(deps, entry.id, nfdName) - expect(moveSpy).not.toHaveBeenCalled() + // The rename is NOT short-circuited: `move` runs and the DB row takes the + // byte-faithful NFD path (no NFC fold). + expect(moveSpy).toHaveBeenCalled() expect(result.id).toBe(entry.id) if (result.origin !== 'external' || entry.origin !== 'external') { throw new Error('expected external entries') } - expect(result.externalPath).toBe(entry.externalPath) // still NFC-canonical + expect(result.name).toBe(nfdName) + expect(result.externalPath).toBe(path.join(tmp, `${nfdName}.txt`)) // byte-faithful NFD }) it('allows a case-only rename when the existing file at target is the same inode', async () => { diff --git a/src/main/services/file/internal/entry/rename.ts b/src/main/services/file/internal/entry/rename.ts index 57e423c43fa..743137c0908 100644 --- a/src/main/services/file/internal/entry/rename.ts +++ b/src/main/services/file/internal/entry/rename.ts @@ -38,10 +38,11 @@ export async function rename(deps: FileManagerDeps, id: FileEntryId, newName: st // `if (!entry.externalPath)` throw was unreachable and has been removed. const dir = path.dirname(entry.externalPath) const ext = entry.ext ? `.${entry.ext}` : '' - // Canonicalize the target so the no-op check below tolerates NFC/NFD, - // trailing-separator, and `.`/`..` noise — `entry.externalPath` is already - // canonical (written through `ensureExternalEntry`), so string equality - // here is a reliable "same logical path" test. + // Canonicalize the target so the no-op check below tolerates trailing- + // separator and `.`/`..` noise — `entry.externalPath` is already canonical + // (byte-faithful, written through `ensureExternalEntry`), so string equality + // here is a reliable "same byte path" test. Canonicalization is byte-faithful + // (no NFC), so NFC and NFD spellings of a name are distinct targets. const oldPath = entry.externalPath const target = canonicalizeFilePath(path.join(dir, `${newName}${ext}`)) // Defense in depth: `SafeNameSchema.parse(newName)` above already rejects diff --git a/src/main/services/file/watcher/__tests__/watcher.test.ts b/src/main/services/file/watcher/__tests__/watcher.test.ts index c4c49711f21..7c887a8d3c9 100644 --- a/src/main/services/file/watcher/__tests__/watcher.test.ts +++ b/src/main/services/file/watcher/__tests__/watcher.test.ts @@ -181,29 +181,27 @@ describe('createDirectoryWatcher', () => { await w.close() }) - // Reverse-direction skip from the NFD test in entry/create.test.ts: - // chokidar emits whatever the OS hands it. On macOS APFS / Windows NTFS the - // FS normalizes to NFC at storage time, so even a file we name with NFD - // bytes gets surfaced as NFC and the case under test (chokidar firing NFD) - // can't be set up locally. On Linux ext4, filenames are opaque bytes — - // writeFile preserves the NFD encoding verbatim, chokidar surfaces it as - // NFD, which is exactly the scenario the production fix targets (a CJK - // file migrated from HFS+ via `rsync -E` arrives in NFD on macOS users' - // disks; we use Linux to *reproduce* that byte pattern under test). + // On Linux ext4, filenames are opaque bytes — writeFile preserves the NFD + // encoding verbatim and chokidar surfaces it as NFD, so we use Linux to + // *reproduce* the byte pattern a CJK/accented file migrated from HFS+ (via + // `rsync -E`) shows up as on a macOS user's disk. Because `externalPath` is + // now stored byte-faithful (no NFC), the DanglingCache reverse index is + // keyed by that exact NFD form and the watcher matches the chokidar event by + // *raw byte equality* — the previous NFC-normalize bridge in `handle()` is + // gone. On Linux the raw event byte-matches the stored key by construction. it.runIf(process.platform === 'linux')( - 'normalizes NFD chokidar paths to NFC before feeding DanglingCache (linux-reproducible regression)', + 'feeds DanglingCache by raw byte equality — NFD event matches the byte-faithful NFD key (no NFC step)', async () => { const nfd = 'qu\u0065\u0301.txt' // q, u, e, combining acute -> NFD const nfc = 'qu\u00E9.txt' // q, u, e-precomposed -> NFC expect(nfd).not.toBe(nfc) // byte-distinct strings reaching us at runtime const writtenPath = path.join(dir, nfd) as FilePath - const canonicalPath = path.join(dir, nfc) as FilePath // DanglingCache's reverse index is populated by `ensureExternalEntry`, - // whose `externalPath` is already NFC-canonical via `canonicalizeFilePath`. - // Mirror that here. - danglingCache.addEntry('e-w-nfd' as FileEntryId, canonicalPath) + // whose `externalPath` is now stored byte-faithful (no NFC). Mirror that + // by registering the entry under the exact NFD bytes on disk. + danglingCache.addEntry('e-w-nfd' as FileEntryId, writtenPath) const w = createDirectoryWatcher(dir as FilePath) await waitForReady(w) @@ -212,13 +210,13 @@ describe('createDirectoryWatcher', () => { if (ev.kind !== 'add') throw new Error('expected add event') expect(ev.path).toBe(writtenPath) - // The cache lookup uses NFC keys; without the NFC-normalize step in - // `handle()` this would miss and the cache would stay 'unknown'. + // The reverse-index key and the chokidar event path are the same + // byte-faithful NFD string, so the lookup hits with no NFC normalization. expect( await danglingCache.check({ id: 'e-w-nfd' as FileEntryId, origin: 'external', - externalPath: canonicalPath, + externalPath: writtenPath, name: 'qué', ext: 'txt', size: null, diff --git a/src/main/services/file/watcher/index.ts b/src/main/services/file/watcher/index.ts index 9fb828a2945..6a572e4f6b0 100644 --- a/src/main/services/file/watcher/index.ts +++ b/src/main/services/file/watcher/index.ts @@ -161,24 +161,27 @@ class DirectoryWatcherImpl implements DirectoryWatcher { * not mirrored — the file is still present; only mtime drift, which the * cache doesn't track. * - * The cache feed is keyed by canonical (NFC) path because `DanglingCache`'s - * reverse index is populated by `ensureExternalEntry`, whose `externalPath` - * is already NFC-canonical via `canonicalizeFilePath`. chokidar emits whatever the - * OS hands it; on macOS APFS that is NFD - * for CJK / accented filenames migrated from HFS+ (or written by tools like - * `rsync -E` that preserve the source encoding). Without normalizing here - * the `path → entryIds` lookup misses and the cache stays stale. The - * outbound `emitter.fire(ev)` keeps the raw OS path so external subscribers - * (e.g. opening the file with the same string chokidar saw) stay coherent - * with what the FS actually has — only the DanglingCache leg gets the NFC - * form, since it is the only leg that compares against canonical keys. + * DanglingCache's reverse index is keyed **byte-faithful**: it is populated + * by `ensureExternalEntry`, whose `externalPath` is stored exactly as the OS + * handed it (byte-faithful lexical form, no NFC). So the watcher matches + * chokidar events by **raw byte equality** — no normalization on either leg. + * (The NFC step that used to sit here existed only to bridge to the old + * NFC-canonical keys; it is removed together with them.) On Linux the raw + * event byte-matches the stored key by construction; on macOS/Windows it + * matches when the path source and chokidar agree on Unicode form. + * + * `check()` — which stats the byte-faithful stored path directly — is the + * correctness baseline, so a missed watcher match is never a correctness + * bug: it only delays a badge update until the next `check()`, which is + * benign and self-healing. See + * `docs/references/file/file-manager-architecture.md §11.3 "Watcher + * Auto-Wiring"`. */ private handle(ev: Extract): void { if (this.closed) return if (ev.kind === 'add' || ev.kind === 'unlink') { - const canonical = ev.path.normalize('NFC') as FilePath const presence = ev.kind === 'add' ? 'present' : 'missing' - danglingCache.onFsEvent(canonical, presence, 'watcher') + danglingCache.onFsEvent(ev.path, presence, 'watcher') } this.emitter.fire(ev) } diff --git a/src/shared/data/types/file/fileEntry.ts b/src/shared/data/types/file/fileEntry.ts index 7aac154f16c..0e45262aa08 100644 --- a/src/shared/data/types/file/fileEntry.ts +++ b/src/shared/data/types/file/fileEntry.ts @@ -211,23 +211,31 @@ export const ExternalEntrySchema = z.strictObject({ origin: z.literal('external'), /** * Absolute filesystem path to the user-provided file, as a - * `CanonicalFilePath`. Canonical form is guaranteed at WRITE time by - * `canonicalizeFilePath` (external-entry insert / rename); this field does - * NOT re-canonicalize on read. A stored value that is not already canonical - * is REJECTED at parse (surfaced via `rowToFileEntrySafe`'s warn-skip) — - * reads never silently rewrite the value. Rejecting rather than repairing - * keeps the lookup/dedup key stable: a re-canonicalization migration is the - * only sanctioned way to fix historically non-canonical rows. + * `CanonicalFilePath` — the byte-faithful lexical form produced by + * `canonicalizeFilePath` (segment-resolve + trailing-strip + drive-upcase, + * NOT Unicode-normalized). This byte-faithful form is guaranteed at WRITE + * time (external-entry insert / rename); this field does NOT re-canonicalize + * on read. A stored value that is not already in that lexical form (a raw + * `./` / `..` / trailing-slash path) is REJECTED at parse (surfaced via + * `rowToFileEntrySafe`'s warn-skip); a byte-faithful path — including one + * carrying NFD Unicode — is accepted as-is. Reads never silently rewrite the + * value: rejecting rather than repairing keeps the lookup/dedup key stable, + * so a re-canonicalization migration is the only sanctioned way to fix + * historically non-canonical rows. */ externalPath: FilePathSchema.refine((s) => { - // Reject a stored value that is not already canonical (no silent repair): - // canonicalizeAbsolutePath throws on structural failure, absorbed here. + // Validate the stored value is already in the byte-faithful lexical form + // produced by `canonicalizeFilePath` (segment-resolve + trailing-strip + + // drive-upcase, NOT Unicode-normalized) — no silent repair. A raw `./` / + // `..` / trailing-slash path is rejected; a byte-faithful path (including + // NFD Unicode) is accepted unchanged. canonicalizeAbsolutePath throws on + // structural failure, absorbed here. try { return s === canonicalizeAbsolutePath(s) } catch { return false } - }, 'externalPath must be canonical (produced via canonicalizeFilePath) before persistence').transform( + }, 'externalPath must be in byte-faithful canonical form (produced via canonicalizeFilePath) before persistence').transform( (s): CanonicalFilePath => s as CanonicalFilePath ) }) diff --git a/src/shared/types/file/__tests__/FilePathSchema.test.ts b/src/shared/types/file/__tests__/FilePathSchema.test.ts index df751e40c1d..d7c929e7865 100644 --- a/src/shared/types/file/__tests__/FilePathSchema.test.ts +++ b/src/shared/types/file/__tests__/FilePathSchema.test.ts @@ -4,10 +4,11 @@ import { FilePathSchema } from '../common' describe('FilePathSchema', () => { // FilePathSchema validates absolute-path SHAPE only and does NOT canonicalize: - // `parse(x)` returns `x` byte-for-byte. Canonicalization (NFC + segment - // resolve + trailing-strip + drive-letter upcase) is a separate concern owned - // by `canonicalizeFilePath` / `canonicalizeAbsolutePath` — its behavior is - // pinned in `@shared/utils/file/__tests__/canonicalize.test.ts`. + // `parse(x)` returns `x` byte-for-byte. Canonicalization (byte-faithful + // lexical cleanup: segment-resolve + trailing-strip + drive-letter upcase, + // NOT Unicode-normalized) is a separate concern owned by `canonicalizeFilePath` + // / `canonicalizeAbsolutePath` — its behavior is pinned in + // `@shared/utils/file/__tests__/canonicalize.test.ts`. it('returns a POSIX absolute path unchanged', () => { expect(FilePathSchema.parse('/Users/me/doc.pdf')).toBe('/Users/me/doc.pdf') diff --git a/src/shared/types/file/common.ts b/src/shared/types/file/common.ts index f9acb202277..04663f339b8 100644 --- a/src/shared/types/file/common.ts +++ b/src/shared/types/file/common.ts @@ -33,11 +33,11 @@ export type FileType = z.infer * SHAPE only — absolute form, no null bytes — and does NOT canonicalize: * `FilePathSchema.parse(x)` returns `x` unchanged (the path exactly as given). * - * The canonical form of a path (NFC + segment-resolve + trailing-separator - * strip + drive-letter upcase) is a distinct `CanonicalFilePath` produced by - * `canonicalizeFilePath()` (`@shared/utils/file/canonicalize`); it is applied - * explicitly at the external-path persistence / lookup boundary, not on every - * `FilePath` parse. + * The canonical form of a path (byte-faithful lexical resolve: segment-resolve + * + trailing-separator strip + drive-letter upcase, NOT Unicode-normalized) is + * a distinct `CanonicalFilePath` produced by `canonicalizeFilePath()` + * (`@shared/utils/file/canonicalize`); it is applied explicitly at the + * external-path persistence / lookup boundary, not on every `FilePath` parse. * * The `z.brand` is a phantom brand — zero runtime cost, dropped on IPC * serialization; receivers re-assert via `FilePathSchema.parse()` at the @@ -62,10 +62,11 @@ export const FilePathSchema = z export type FilePath = z.infer /** - * A `FilePath` additionally proven to be in canonical form - * (`canonicalizeAbsolutePath`: NFC + segment-resolve + trailing-separator - * strip + drive-letter upcase). This is the form persisted in - * `file_entry.externalPath` and used as the dedup / lookup key. + * A `FilePath` additionally proven to be in canonical form — the + * byte-faithful, lexically-resolved output of `canonicalizeAbsolutePath` + * (segment-resolve + trailing-separator strip + drive-letter upcase), NOT + * Unicode-normalized. This is the form persisted in `file_entry.externalPath` + * and used as the dedup / lookup key. * * TS-only phantom brand with a STRING-LITERAL key (not a `unique symbol`): * `FileEntry.externalPath` flows into preload's inferred `WindowApiType` diff --git a/src/shared/types/file/ipc.ts b/src/shared/types/file/ipc.ts index 0371bb12eb3..055e7e3d7ff 100644 --- a/src/shared/types/file/ipc.ts +++ b/src/shared/types/file/ipc.ts @@ -123,10 +123,11 @@ export type CreateInternalEntryIpcParams = * * `externalPath` is typed as `FilePath` at this IPC boundary — shape-validated * only (absolute form, no null bytes; see `FilePathSchema`), NOT canonicalized. - * Canonicalization into the stored `CanonicalFilePath` form (NFC normalize, - * resolve segments, strip trailing separator, drive-letter upcase) happens - * inside `ensureExternalEntry`, via `canonicalizeFilePath()` - * (`@shared/utils/file/canonicalize`) — not at IPC parse time. + * Canonicalization into the stored `CanonicalFilePath` form (byte-faithful + * lexical cleanup: resolve segments, strip trailing separator, drive-letter + * upcase — NOT Unicode-normalized) happens inside `ensureExternalEntry`, via + * `canonicalizeFilePath()` (`@shared/utils/file/canonicalize`) — not at IPC + * parse time. * * What stays main-only is the **disambiguation** step beyond canonicalization: * `ensureExternalEntry` additionally runs `fs.realpath` to resolve diff --git a/src/shared/utils/file/__tests__/canonicalize.test.ts b/src/shared/utils/file/__tests__/canonicalize.test.ts index 00cfccec908..58f4d41fca1 100644 --- a/src/shared/utils/file/__tests__/canonicalize.test.ts +++ b/src/shared/utils/file/__tests__/canonicalize.test.ts @@ -3,12 +3,13 @@ * implementation that backs the FileEntry schema's `externalPath` refine. * * For inputs that match the host platform, the result must equal what the - * main-side `path.resolve` + NFC + trailing-strip pipeline produces; this - * keeps the canonicalize-on-write path (`canonicalizeFilePath`, applied at the - * external-path boundary) and the schema's `externalPath` canonical-equivalence - * refine in lockstep. Cross-platform cases (Windows-shaped paths processed on - * POSIX hosts and vice versa) are pinned by handcrafted expectations because - * `path.resolve` is host-aware and can't be used as the oracle there. + * main-side `path.resolve` + trailing-strip pipeline produces (byte-faithful, + * NO Unicode normalization); this keeps the canonicalize-on-write path + * (`canonicalizeFilePath`, applied at the external-path boundary) and the + * schema's `externalPath` canonical-equivalence refine in lockstep. + * Cross-platform cases (Windows-shaped paths processed on POSIX hosts and vice + * versa) are pinned by handcrafted expectations because `path.resolve` is + * host-aware and can't be used as the oracle there. */ import path from 'node:path' @@ -18,8 +19,10 @@ import { describe, expect, it } from 'vitest' import { canonicalizeAbsolutePath, canonicalizeFilePath } from '../canonicalize' function nodeCanonicalize(raw: string): string { + // Byte-faithful: `path.resolve` (segment resolve) + trailing-strip only. + // No `.normalize('NFC')` — canonicalization deliberately preserves the + // exact bytes so the path reaches the real file on every filesystem. let normalized = path.resolve(raw) - normalized = normalized.normalize('NFC') if (normalized.length > 1 && (normalized.endsWith(path.sep) || normalized.endsWith('/'))) { normalized = normalized.slice(0, -1) } @@ -48,10 +51,14 @@ describe('canonicalizeAbsolutePath — POSIX', () => { expect(canonicalizeAbsolutePath('/foo//bar')).toBe('/foo/bar') }) - it('NFC-normalizes Unicode', () => { - const nfd = '/users/qué' // qu + e + combining acute - const nfc = '/users/qué' // qu + é - expect(canonicalizeAbsolutePath(nfd)).toBe(nfc) + it('does NOT Unicode-normalize \u2014 returns decomposed (NFD) input byte-faithfully unchanged', () => { + // Canonicalization is byte-faithful: an NFD path stays NFD so it still + // reaches the real file on normalization-sensitive filesystems (Linux + // ext4). ASCII \\u escapes keep the literals stable across tooling. + const nfd = '/users/qu\u0065\u0301' // qu + e + combining acute (NFD) + const nfc = '/users/qu\u00E9' // qu + e-precomposed (NFC) + expect(nfd).not.toBe(nfc) // byte-distinct inputs + expect(canonicalizeAbsolutePath(nfd)).toBe(nfd) // unchanged, NOT folded to NFC }) it('matches node:path on the host platform for representative inputs', () => { diff --git a/src/shared/utils/file/canonicalize.ts b/src/shared/utils/file/canonicalize.ts index 9f4176695c0..f02ede433a4 100644 --- a/src/shared/utils/file/canonicalize.ts +++ b/src/shared/utils/file/canonicalize.ts @@ -13,8 +13,18 @@ * * 0. Reject null bytes (`\0`). * 1. Resolve segments (`.`, `..`, repeated separators). - * 2. Unicode NFC normalize. - * 3. Strip trailing separator (except on a bare drive / POSIX root). + * 2. Strip trailing separator (except on a bare drive / POSIX root). + * 3. Windows only: uppercase the drive letter, normalize separators to `\`. + * + * This is reachability-safe **lexical** cleanup only: every step is a + * filesystem no-op for *which file the path reaches* — the cleaned string + * still resolves to the same on-disk entry on every platform. The result is + * therefore **byte-faithful**: Unicode (NFC) normalization is deliberately + * NOT performed, so a canonicalized path keeps the exact bytes the OS handed + * us and always reaches the real file even on normalization-sensitive + * filesystems (Linux ext4/btrfs), where an NFC-rewritten path would not exist + * on disk. See `docs/references/file/file-manager-architecture.md §1.2 + * "Rejected: Unicode (NFC) normalization of externalPath"`. * * The input **must already be absolute**. POSIX absolute (`/…`) and Windows * absolute (`X:\…` or `X:/…`) are both accepted; mixed-platform input is @@ -23,13 +33,11 @@ * * ## Rule-evolution discipline * - * Changing the normalization steps below desynchronizes historical rows - * (written under the old rule) from new queries (running under the new - * rule). Any such change MUST ship with a paired Drizzle migration that - * re-canonicalizes every existing `file_entry.externalPath` and re-points - * association rows whose canonical forms now collide. See - * `docs/references/file/file-manager-architecture.md §1.2 Rule evolution - * discipline`. + * The steps above are lexical only and do not depend on filesystem Unicode + * semantics, so they do NOT carry the "changing the rule desyncs historical + * rows, requiring a paired re-canonicalize migration" hazard that the removed + * NFC step did. See `docs/references/file/file-manager-architecture.md §1.2 + * "Residual normalization discipline"`. */ import type { CanonicalFilePath } from '@shared/types/file/common' @@ -40,14 +48,16 @@ export function canonicalizeAbsolutePath(raw: string): string { } const isWindows = /^[A-Za-z]:[/\\]/.test(raw) const normalized = isWindows ? canonicalizeWindows(raw) : canonicalizePosix(raw) - return normalized.normalize('NFC') + return normalized } /** * The sole sanctioned producer of `CanonicalFilePath`: run the pure-JS * canonicalization and brand the result. Callers needing a canonical * lookup/persistence key (external-entry write + `findByExternalPath`) go - * through here instead of `as`-casting into the brand. + * through here instead of `as`-casting into the brand. The produced value is + * the byte-faithful, lexically-resolved form (segment-resolve + trailing-strip + * + Windows drive-upcase) — NOT Unicode-normalized. * * @throws if `input` is not absolute / contains a null byte (delegated to * `canonicalizeAbsolutePath`). From dbba1e562f3bd5ca425f980b75bafdff057feec6 Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sun, 5 Jul 2026 16:59:08 +0800 Subject: [PATCH 13/22] refactor(file): allow ensureExternalEntry to create dangling entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An external FileEntry may legitimately reference a path that is not currently on disk — dangling is a first-class state. Replace the hard `fs.stat` existence requirement in `ensureExternal` with a best-effort presence probe: ENOENT/ENOTDIR now creates a dangling entry (seeding a 'missing' DanglingCache observation) instead of throwing, while genuine FS faults (EACCES/EIO/…) still rethrow via raw `.code` discrimination. The `fs.realpath` case-collision probe needs the file on disk, so the whole findCaseInsensitivePeers/resolveCaseCollisionPeer block is gated on presence; a missing case-colliding path is left to the DB `lower(externalPath)` UNIQUE index to reject at INSERT. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- .../file/file-manager-architecture.md | 5 +- .../__tests__/FileManager.integration.test.ts | 35 ++++- .../internal/entry/__tests__/create.test.ts | 37 +++++ .../services/file/internal/entry/create.ts | 127 +++++++++++------- 4 files changed, 147 insertions(+), 57 deletions(-) diff --git a/docs/references/file/file-manager-architecture.md b/docs/references/file/file-manager-architecture.md index 45536bf7a24..14a197c4319 100644 --- a/docs/references/file/file-manager-architecture.md +++ b/docs/references/file/file-manager-architecture.md @@ -99,9 +99,12 @@ If real NFD/NFC duplicates are ever *observed*, the correct fix is an FS-aware ` Case-insensitive uniqueness on `externalPath` is enforced at **both layers**: the functional UNIQUE index `fe_external_path_lower_unique_idx` (DB) and `ensureExternalEntry`'s pre-INSERT collision check (application). The two-layer scheme keeps the DB-level guarantee unbreakable while letting the application disambiguate the FS-correct interpretation case-by-case. +Presence is **probed, not required**: after the `findByExternalPath` miss, `ensureExternalEntry` runs one `fs.stat`. `ENOENT` / `ENOTDIR` ("the file is simply not there") does **not** abort — the entry is created **dangling** (an external ref to an off-disk path is a first-class state; see [§3.3 Dangling Model](#33-dangling-model)), seeding a `'missing'` DanglingCache observation. Only a *genuine* FS fault (`EACCES`, `EIO`, …) rethrows. The `fs.realpath` collision probe below **requires the file on disk**, so it runs **only when the file is present**; when the new path is missing the block is skipped and a case-colliding missing path is left to the DB `lower(externalPath)` UNIQUE index to reject at INSERT (`SQLITE_CONSTRAINT`) — the correct outcome, just without the friendlier pre-INSERT error we cannot produce without the file. + ```typescript // Inside ensureExternalEntry, AFTER canonicalize, AFTER findByExternalPath miss, -// AFTER fs.stat verifies the new path exists, BEFORE INSERT: +// AFTER the fs.stat presence probe reports the new path is present (a MISSING +// path skips this block and is created dangling), BEFORE INSERT: const peers = await fileEntryService.findCaseInsensitivePeers(canonicalPath) if (peers.length > 0) { // `fs.realpath` is the platform-correct probe for "are these the same FS diff --git a/src/main/services/file/__tests__/FileManager.integration.test.ts b/src/main/services/file/__tests__/FileManager.integration.test.ts index c92f4c5d163..bf60d36f772 100644 --- a/src/main/services/file/__tests__/FileManager.integration.test.ts +++ b/src/main/services/file/__tests__/FileManager.integration.test.ts @@ -543,29 +543,50 @@ describe('FileManager (integration)', () => { expect(rows).toHaveLength(2) }) - it('INT-15b: batchEnsureExternalEntries dedupes within-batch duplicate paths and aggregates per-item failures', async () => { + it('INT-15b: batchEnsureExternalEntries dedupes duplicates, creates a dangling entry for a missing path, and aggregates a genuine per-item failure', async () => { const same = path.join(tmp, 'dedupe.txt') await writeFile(same, 'x') - const missing = path.join(tmp, 'no-such-file.txt') + const missing = path.join(tmp, 'no-such-file.txt') // absent on disk → dangling success + const guarded = path.join(tmp, 'guarded.txt') // stat EACCES → genuine per-item failure + + // A missing external path is a first-class dangling state, not an error, so + // only a *genuine* FS fault still fails an item. Drive the fs stat seam to + // throw EACCES for `guarded` alone; every other path stats for real. + const fsModule = await import('@main/utils/file/fs') + const realStat = fsModule.stat + const statSpy = vi.spyOn(fsModule, 'stat').mockImplementation(async (p) => { + if (p === guarded) throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + return realStat(p) + }) const result = await fm.batchEnsureExternalEntries([ { externalPath: same as never }, { externalPath: same as never }, - { externalPath: missing as never } + { externalPath: missing as never }, + { externalPath: guarded as never } ]) // Two `same`-path inputs collapse to ONE DB row, but BOTH appear in // succeeded with the matching sourceRef so callers can still correlate // each input — that is the dedupe contract the BatchCreateResult split - // (I3) was designed to express. - expect(result.succeeded).toHaveLength(2) + // (I3) was designed to express. The missing path succeeds as a DANGLING + // entry; only the EACCES fault lands in failed. + expect(result.succeeded).toHaveLength(3) expect(result.succeeded[0].sourceRef).toBe(same) expect(result.succeeded[1].sourceRef).toBe(same) expect(result.succeeded[0].id).toBe(result.succeeded[1].id) + expect(result.succeeded[2].sourceRef).toBe(missing) expect(result.failed).toHaveLength(1) - expect(result.failed[0].sourceRef).toBe(missing) - // The DB must contain exactly one external row for `same`. + expect(result.failed[0].sourceRef).toBe(guarded) + // The missing path produced a real external row whose dangling state is 'missing'. + const missingEntry = await fm.findByExternalPath(FilePathSchema.parse(missing)) + if (!missingEntry) throw new Error('expected a dangling entry for the missing path') + expect(await fm.getDanglingState({ id: missingEntry.id })).toBe('missing') + // Exactly one external row for `same`; the EACCES fault left no row. const rows = await dbh.db.select().from(fileEntryTable) expect(rows.filter((r) => r.externalPath === same)).toHaveLength(1) + expect(rows.filter((r) => r.externalPath === guarded)).toHaveLength(0) + + statSpy.mockRestore() }) it('INT-8: batchGetDanglingStates returns "unknown" for ids that have no entry', async () => { diff --git a/src/main/services/file/internal/entry/__tests__/create.test.ts b/src/main/services/file/internal/entry/__tests__/create.test.ts index 816a07e6530..1fafcb37d90 100644 --- a/src/main/services/file/internal/entry/__tests__/create.test.ts +++ b/src/main/services/file/internal/entry/__tests__/create.test.ts @@ -233,6 +233,43 @@ describe('internal/entry/create.createInternal', () => { }) }) + describe('ensureExternal dangling create (missing path is a first-class state, not an error)', () => { + it('creates an external entry for a path that does not exist and seeds a "missing" observation', async () => { + // The file is never written to disk. `ensureExternal` probes presence + // best-effort: ENOENT means "simply absent" → create the entry DANGLING + // rather than throw (an external ref to an off-disk path is a first-class + // state). The returned row is a real external entry, and the DanglingCache + // observation seeded on insert is 'missing' — i.e. a subsequent + // `danglingCache.check(entry)` resolves to 'missing'. + const missing = path.join(tmp, 'never-written.pdf') + const entry = await ensureExternal(deps, { externalPath: missing as FilePath }) + if (entry.origin !== 'external') throw new Error('expected external entry') + expect(entry.externalPath).toBe(missing) + expect(entry.name).toBe('never-written') + expect(entry.ext).toBe('pdf') + // Persisted, not just returned. + expect(fileEntryService.getById(entry.id).id).toBe(entry.id) + // Reverse-indexed and seeded 'missing' (dangling), NOT 'present'. + expect(deps.danglingCache.addEntry).toHaveBeenCalledWith(entry.id, entry.externalPath) + expect(deps.danglingCache.onFsEvent).toHaveBeenCalledWith(entry.externalPath, 'missing', 'ops') + }) + + it('rethrows a non-ENOENT stat error (EACCES) instead of creating a dangling entry', async () => { + // ENOENT / ENOTDIR flip to dangling-create; every other errno is a + // genuine FS fault that must preserve the old fail-loud behavior. Drive + // the same `@main/utils/file/fs` stat seam that write/rename suites mock, + // making it throw an EACCES-coded error. + const fsModule = await import('@main/utils/file/fs') + const guarded = path.join(tmp, 'guarded.txt') + const statErr = Object.assign(new Error('permission denied'), { code: 'EACCES' }) + vi.spyOn(fsModule, 'stat').mockRejectedValue(statErr) + await expect(ensureExternal(deps, { externalPath: guarded as FilePath })).rejects.toBe(statErr) + // No entry inserted, no cache observation recorded on the fail-loud path. + expect(deps.danglingCache.addEntry).not.toHaveBeenCalled() + expect(deps.danglingCache.onFsEvent).not.toHaveBeenCalled() + }) + }) + describe('ensureExternal case-collision policy (M2: functional unique index + fs.realpath)', () => { // Background: `fe_external_path_lower_unique_idx` enforces case-insensitive // uniqueness on `externalPath` at the DB layer. Application-side, the diff --git a/src/main/services/file/internal/entry/create.ts b/src/main/services/file/internal/entry/create.ts index 0155f8dc0a9..06e0b481bb4 100644 --- a/src/main/services/file/internal/entry/create.ts +++ b/src/main/services/file/internal/entry/create.ts @@ -169,57 +169,86 @@ export async function createInternal(deps: FileManagerDeps, params: CreateIntern * boundary validates shape only, so this function canonicalizes the input via * `canonicalizeFilePath` and derives every downstream value (lookup, dedup, * name/ext projection, persisted `externalPath`) from that `CanonicalFilePath`. - * Path existence is verified via `fs.stat` before insert; ENOENT propagates. + * + * External entries may be created **dangling** — an external reference to a + * path that is not currently on disk is a first-class state (see + * `DanglingState`), so presence is probed best-effort, never required: + * `fs.stat` succeeding records `'present'`; ENOENT / ENOTDIR ("the file is + * simply not there") records `'missing'` and still inserts a dangling entry; + * any other errno (EACCES, EIO, …) is a genuine FS fault and rethrows. The + * `fs.realpath` case-collision probe needs the file on disk, so it runs only + * when the file is present. */ export async function ensureExternal(deps: FileManagerDeps, params: EnsureExternalEntryParams): Promise { const canonical = canonicalizeFilePath(params.externalPath) const existing = deps.fileEntryService.findByExternalPath(canonical) if (existing) return existing - await fsStat(canonical) - // Case-insensitive peer lookup is index-backed via the - // `fe_external_path_lower_unique_idx` functional UNIQUE on `lower(externalPath)`. - // The same index hard-rejects an INSERT that would collide with an existing - // peer's lowercased form, so we MUST resolve the collision at the - // application layer before attempting the INSERT — otherwise a legitimate - // distinct-file reference on a case-sensitive filesystem (Linux ext4 / - // case-sensitive APFS volume) would surface as an opaque SQLITE_CONSTRAINT. - // - // Disambiguation strategy: `fs.realpath`. On case-insensitive filesystems - // (macOS APFS default, Windows NTFS default) the FS itself folds case, - // so `realpath('/foo/A.txt')` and `realpath('/foo/a.txt')` return the same - // on-disk canonical string → same logical file, reuse the existing peer. - // On case-sensitive filesystems the two paths resolve to distinct strings - // (or one ENOENTs) → genuine distinct files, throw with peer info so the - // caller can decide (rename / surface to user). - // - // SELECT failure (transient DB lock, connection drop) propagates; the - // subsequent INSERT would fail at the same boundary with a more - // diagnosable stack, so wrapping in try/catch here only hides the real - // error one stack frame earlier. - const peers = deps.fileEntryService.findCaseInsensitivePeers(canonical) - if (peers.length > 0) { - const reusable = await resolveCaseCollisionPeer(canonical, peers) - if (reusable) { - logger.info('ensureExternal: reusing case-collision peer (fs.realpath confirmed same FS entry)', { - newPath: canonical, - peerId: reusable.id, - peerPath: (reusable as { externalPath: string }).externalPath - }) - return reusable + // Presence probe (best-effort, not a precondition). ENOENT / ENOTDIR mean + // the file is simply absent → create the entry dangling. Any other errno + // (EACCES, EIO, …) is a genuine FS fault, NOT a "dangling" situation, so we + // preserve the old fail-loud behavior and rethrow. Discriminate on the raw + // `.code`, mirroring `danglingCache.ts` `defaultStatProbe` / `pathStatus.ts`. + let present: boolean + try { + await fsStat(canonical) + present = true + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code !== 'ENOENT' && code !== 'ENOTDIR') throw err + present = false + } + // Case-collision resolution requires the file on disk: `fs.realpath` cannot + // probe an absent path, so we gate the whole peer block on `present`. When + // the new path is missing we skip it — a missing path that case-collides + // with an existing entry is still rejected by the DB `lower(externalPath)` + // UNIQUE index at INSERT (SQLITE_CONSTRAINT), which is the correct outcome; + // we simply cannot produce the friendlier pre-INSERT error without the file. + if (present) { + // Case-insensitive peer lookup is index-backed via the + // `fe_external_path_lower_unique_idx` functional UNIQUE on `lower(externalPath)`. + // The same index hard-rejects an INSERT that would collide with an existing + // peer's lowercased form, so we MUST resolve the collision at the + // application layer before attempting the INSERT — otherwise a legitimate + // distinct-file reference on a case-sensitive filesystem (Linux ext4 / + // case-sensitive APFS volume) would surface as an opaque SQLITE_CONSTRAINT. + // + // Disambiguation strategy: `fs.realpath`. On case-insensitive filesystems + // (macOS APFS default, Windows NTFS default) the FS itself folds case, + // so `realpath('/foo/A.txt')` and `realpath('/foo/a.txt')` return the same + // on-disk canonical string → same logical file, reuse the existing peer. + // On case-sensitive filesystems the two paths resolve to distinct strings + // (or one ENOENTs) → genuine distinct files, throw with peer info so the + // caller can decide (rename / surface to user). + // + // SELECT failure (transient DB lock, connection drop) propagates; the + // subsequent INSERT would fail at the same boundary with a more + // diagnosable stack, so wrapping in try/catch here only hides the real + // error one stack frame earlier. + const peers = deps.fileEntryService.findCaseInsensitivePeers(canonical) + if (peers.length > 0) { + const reusable = await resolveCaseCollisionPeer(canonical, peers) + if (reusable) { + logger.info('ensureExternal: reusing case-collision peer (fs.realpath confirmed same FS entry)', { + newPath: canonical, + peerId: reusable.id, + peerPath: (reusable as { externalPath: string }).externalPath + }) + return reusable + } + // No peer is the same FS entity. On a case-sensitive filesystem these + // are legitimately distinct files, but the DB unique constraint forbids + // the insert. Throw with full peer detail so the caller can act + // (rename one of the colliding paths, or surface the conflict to the + // user). This is a deliberate departure from the previous "warn-only" + // contract — the application-layer hard guarantee on lowered-path + // uniqueness is what option (c) brings. + throw new Error( + `ensureExternal: case-collision with existing entries — fs.realpath confirms different FS entities. ` + + `New: ${canonical}; conflicting peers: ${peers + .map((p) => `${p.id}=${(p as { externalPath: string }).externalPath}`) + .join(', ')}` + ) } - // No peer is the same FS entity. On a case-sensitive filesystem these - // are legitimately distinct files, but the DB unique constraint forbids - // the insert. Throw with full peer detail so the caller can act - // (rename one of the colliding paths, or surface the conflict to the - // user). This is a deliberate departure from the previous "warn-only" - // contract — the application-layer hard guarantee on lowered-path - // uniqueness is what option (c) brings. - throw new Error( - `ensureExternal: case-collision with existing entries — fs.realpath confirms different FS entities. ` + - `New: ${canonical}; conflicting peers: ${peers - .map((p) => `${p.id}=${(p as { externalPath: string }).externalPath}`) - .join(', ')}` - ) } // `name` and `ext` are pure projections of `externalPath` — derived here, // not accepted from callers. Doc-stated invariant: "external `name` is a @@ -236,11 +265,11 @@ export async function ensureExternal(deps: FileManagerDeps, params: EnsureExtern externalPath: canonical }) // Reverse-index hook: subsequent watcher / opportunistic ops events for - // `canonical` should reach this entry id. The fs.stat above succeeded — - // record a fresh 'present' observation so any imminent UI query - // short-circuits the cold-stat path. + // `canonical` should reach this entry id. Record the probed presence as a + // fresh 'present' / 'missing' observation so any imminent UI query + // short-circuits the cold-stat path (a dangling create seeds 'missing'). deps.danglingCache.addEntry(inserted.id, canonical) - deps.danglingCache.onFsEvent(canonical, 'present', 'ops') + deps.danglingCache.onFsEvent(canonical, present ? 'present' : 'missing', 'ops') return inserted } From 0214a9f8701928a8ed0203d3fbbd641dd74bca9d Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sun, 5 Jul 2026 20:36:54 +0800 Subject: [PATCH 14/22] docs(file): sync stale path-type references to explicit canonicalizeFilePath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reference docs still described the superseded "FilePathSchema's transform canonicalizes at parse" model (from before FilePath became shape-only): - file-manager-architecture.md §13.3 appendix table said "canonicalizeAbsolutePath (via FilePathSchema's transform)" -> corrected to "(via the canonicalizeFilePath() factory)". - architecture.md ensureExternalEntry row said the entry point "parses it through FilePathSchema, whose transform normalizes it" -> corrected to validate shape via FilePathSchema, then canonicalize via canonicalizeFilePath() inside ensureExternalEntry before the upsert. Addresses self-review R16/R17. The byte-faithful §1.2 rewrite already fixed the in-section references; these two lived in an appendix table and a separate doc. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- docs/references/file/architecture.md | 2 +- docs/references/file/file-manager-architecture.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/references/file/architecture.md b/docs/references/file/architecture.md index e12335b1bb7..65ca114e3c3 100644 --- a/docs/references/file/architecture.md +++ b/docs/references/file/architecture.md @@ -299,7 +299,7 @@ All operations that can act on any file (FileEntry or arbitrary path) **accept a | Method | Description | |---|---| | `createInternalEntry` / `batchCreateInternalEntries` | Create a new Cherry-owned FileEntry (writes to `{userData}/Data/Files/{id}.{ext}`; each call produces an independent new entry, no conflict possible) | -| `ensureExternalEntry` / `batchEnsureExternalEntries` | Pure upsert by `externalPath`—the entry point first parses it through `FilePathSchema`, whose transform normalizes it via `canonicalizeAbsolutePath` (see `canonicalize.ts`); reuses the existing entry with the same path or inserts a new one. Idempotent by design—callers may safely repeat calls. No "restore" branch: external entries cannot be trashed. External rows carry no stored `size` (always `null`); live values come from `getMetadata`. | +| `ensureExternalEntry` / `batchEnsureExternalEntries` | Pure upsert by `externalPath`—the entry point first validates the path shape via `FilePathSchema` (shape-only, no rewrite), then `ensureExternalEntry` canonicalizes it to the byte-faithful lexical form via the `canonicalizeFilePath()` factory (see `canonicalize.ts`) before matching; reuses the existing entry with the same path or inserts a new one. Idempotent by design—callers may safely repeat calls. No "restore" branch: external entries cannot be trashed. External rows carry no stored `size` (always `null`); live values come from `getMetadata`. | | `trash` / `restore` | Soft delete based on deletedAt (DB only). **Internal-origin only** — external-origin entries cannot be trashed (`fe_external_no_delete` CHECK); passing an external id throws. | | `batchTrash` / `batchRestore` | Batch versions of `trash` / `restore` — same internal-origin-only rule. | | `batchPermanentDelete` | Batch version of `permanentDelete`. | diff --git a/docs/references/file/file-manager-architecture.md b/docs/references/file/file-manager-architecture.md index 14a197c4319..2ea740da9b8 100644 --- a/docs/references/file/file-manager-architecture.md +++ b/docs/references/file/file-manager-architecture.md @@ -1371,7 +1371,7 @@ This checklist is the canonical addition procedure. A PR introducing a new origi | Location | Change required | |---|---| | `src/main/services/file/utils/pathResolver.ts` → `resolvePhysicalPath` | Add the new `entry.origin` branch; decide storage layout | -| `src/shared/utils/file/canonicalize.ts` → `canonicalizeAbsolutePath` (via `FilePathSchema`'s transform) | If the new variant is path-based and distinct from `'external'`, decide whether it shares the canonical form or needs its own normalization + brand | +| `src/shared/utils/file/canonicalize.ts` → `canonicalizeAbsolutePath` (via the `canonicalizeFilePath()` factory) | If the new variant is path-based and distinct from `'external'`, decide whether it shares the canonical form or needs its own normalization + brand | ### 13.4 Behavior Policy Matrix From d8fa092513585c9230297f88273def49f61a0b80 Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sun, 5 Jul 2026 20:37:28 +0800 Subject: [PATCH 15/22] refactor(file-types): derive CanonicalFilePath from a Zod schema; privatize canonicalizeAbsolutePath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CanonicalFilePath was a hand-written intersection (FilePath & { __canonical }) while FilePath is z.infer of its schema — an asymmetry rooted in an assumed TS2527/TS4023 hazard for a second brand in preload's WindowApiType aggregate. An experiment (FilePathSchema.brand<'CanonicalFilePath'>() flowing through WindowApiType) typechecks clean on node+web+aicore, so the hazard was a local `unique symbol`, not Zod's brand (which FilePath already uses). Unify the style. - canonicalize.ts now owns the whole canonical layer (co-located with its schema, the only cycle-free home since the schema needs both FilePathSchema and the algorithm; utils -> types is the correct direction): - CanonicalFilePathSchema = FilePathSchema.refine(isCanonicalFilePath).brand, assert-only (rejects non-canonical, never repairs) — the "no silent repair" contract the FileEntry externalPath read path depends on. - type CanonicalFilePath = z.infer. - isCanonicalFilePath predicate (safe as a refine: false, never throws). - canonicalizeFilePath() brands via the schema — no `as` cast. - canonicalizeAbsolutePath is now module-private (dropped from the utils barrel); its algorithm is covered transitively via canonicalizeFilePath. - fileEntry.ts externalPath uses CanonicalFilePathSchema directly, dropping the bespoke refine + `as CanonicalFilePath` transform (R4 semantics preserved). - common.ts drops the hand-written type + its TS2527 workaround note; the type and schema now live in / re-export from @shared/utils/file. - FileEntryService imports CanonicalFilePath from @shared/utils/file. - canonicalize.test.ts ported to the public surface (canonicalizeFilePath / CanonicalFilePathSchema / isCanonicalFilePath), incl. assert-only accept/reject. Both production `as CanonicalFilePath` casts are eliminated (factory + schema), so the no-as-filepath exemptions for them are gone. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- src/main/data/services/FileEntryService.ts | 2 +- .../__tests__/FileEntryService.test.ts | 3 +- src/shared/data/types/file/fileEntry.ts | 34 ++--- .../file/__tests__/FilePathSchema.test.ts | 2 +- src/shared/types/file/common.ts | 19 +-- src/shared/types/file/index.ts | 1 - .../utils/file/__tests__/canonicalize.test.ts | 127 ++++++++++++------ src/shared/utils/file/canonicalize.ts | 80 ++++++++--- src/shared/utils/file/index.ts | 7 +- 9 files changed, 172 insertions(+), 103 deletions(-) diff --git a/src/main/data/services/FileEntryService.ts b/src/main/data/services/FileEntryService.ts index 593b78cc9ee..e1fea24c69c 100644 --- a/src/main/data/services/FileEntryService.ts +++ b/src/main/data/services/FileEntryService.ts @@ -32,7 +32,7 @@ import type { FileEntryListResponse, FileEntryStats } from '@shared/data/api/sch import type { FileEntry, FileEntryId, FileEntryOrigin } from '@shared/data/types/file' import { ExternalEntrySchema, FileEntrySchema, InternalEntrySchema, SafeNameSchema } from '@shared/data/types/file' import { chatMessageSourceType, paintingSourceType } from '@shared/data/types/file/ref' -import type { CanonicalFilePath } from '@shared/types/file' +import type { CanonicalFilePath } from '@shared/utils/file' import { and, asc, count, eq, isNotNull, isNull, type SQL, sql, type SQLWrapper } from 'drizzle-orm' import { v7 as uuidv7 } from 'uuid' import * as z from 'zod' diff --git a/src/main/data/services/__tests__/FileEntryService.test.ts b/src/main/data/services/__tests__/FileEntryService.test.ts index d55088eb78e..f6898c12229 100644 --- a/src/main/data/services/__tests__/FileEntryService.test.ts +++ b/src/main/data/services/__tests__/FileEntryService.test.ts @@ -5,7 +5,8 @@ import { paintingTable } from '@data/db/schemas/painting' import { topicTable } from '@data/db/schemas/topic' import { DataApiError, ErrorCode } from '@shared/data/api' import type { FileEntryId } from '@shared/data/types/file' -import type { CanonicalFilePath, FilePath } from '@shared/types/file' +import type { FilePath } from '@shared/types/file' +import type { CanonicalFilePath } from '@shared/utils/file' import { setupTestDatabase } from '@test-helpers/db' import { MockMainDbServiceExport, MockMainDbServiceUtils } from '@test-mocks/main/DbService' import { mockMainLoggerService } from '@test-mocks/MainLoggerService' diff --git a/src/shared/data/types/file/fileEntry.ts b/src/shared/data/types/file/fileEntry.ts index 0e45262aa08..9c35d5c76e4 100644 --- a/src/shared/data/types/file/fileEntry.ts +++ b/src/shared/data/types/file/fileEntry.ts @@ -98,8 +98,8 @@ * unmanaged `@main/utils/file/fs.remove(path)` separately. */ -import { type CanonicalFilePath, FilePathSchema, SafeExtSchema } from '@shared/types/file/common' -import { canonicalizeAbsolutePath } from '@shared/utils/file/canonicalize' +import { SafeExtSchema } from '@shared/types/file/common' +import { CanonicalFilePathSchema } from '@shared/utils/file/canonicalize' import * as z from 'zod' import { SafeNameSchema, TimestampSchema } from './essential' @@ -213,31 +213,17 @@ export const ExternalEntrySchema = z.strictObject({ * Absolute filesystem path to the user-provided file, as a * `CanonicalFilePath` — the byte-faithful lexical form produced by * `canonicalizeFilePath` (segment-resolve + trailing-strip + drive-upcase, - * NOT Unicode-normalized). This byte-faithful form is guaranteed at WRITE - * time (external-entry insert / rename); this field does NOT re-canonicalize - * on read. A stored value that is not already in that lexical form (a raw - * `./` / `..` / trailing-slash path) is REJECTED at parse (surfaced via - * `rowToFileEntrySafe`'s warn-skip); a byte-faithful path — including one - * carrying NFD Unicode — is accepted as-is. Reads never silently rewrite the - * value: rejecting rather than repairing keeps the lookup/dedup key stable, + * NOT Unicode-normalized). Validated by `CanonicalFilePathSchema`, which is + * assert-only: this byte-faithful form is guaranteed at WRITE time + * (external-entry insert / rename), and on READ a stored value not already in + * that lexical form (a raw `./` / `..` / trailing-slash path) is REJECTED + * (surfaced via `rowToFileEntrySafe`'s warn-skip), never silently repaired — + * a byte-faithful path, including one carrying NFD Unicode, is accepted + * as-is. Rejecting rather than repairing keeps the lookup/dedup key stable, * so a re-canonicalization migration is the only sanctioned way to fix * historically non-canonical rows. */ - externalPath: FilePathSchema.refine((s) => { - // Validate the stored value is already in the byte-faithful lexical form - // produced by `canonicalizeFilePath` (segment-resolve + trailing-strip + - // drive-upcase, NOT Unicode-normalized) — no silent repair. A raw `./` / - // `..` / trailing-slash path is rejected; a byte-faithful path (including - // NFD Unicode) is accepted unchanged. canonicalizeAbsolutePath throws on - // structural failure, absorbed here. - try { - return s === canonicalizeAbsolutePath(s) - } catch { - return false - } - }, 'externalPath must be in byte-faithful canonical form (produced via canonicalizeFilePath) before persistence').transform( - (s): CanonicalFilePath => s as CanonicalFilePath - ) + externalPath: CanonicalFilePathSchema }) /** diff --git a/src/shared/types/file/__tests__/FilePathSchema.test.ts b/src/shared/types/file/__tests__/FilePathSchema.test.ts index d7c929e7865..022eae66044 100644 --- a/src/shared/types/file/__tests__/FilePathSchema.test.ts +++ b/src/shared/types/file/__tests__/FilePathSchema.test.ts @@ -7,7 +7,7 @@ describe('FilePathSchema', () => { // `parse(x)` returns `x` byte-for-byte. Canonicalization (byte-faithful // lexical cleanup: segment-resolve + trailing-strip + drive-letter upcase, // NOT Unicode-normalized) is a separate concern owned by `canonicalizeFilePath` - // / `canonicalizeAbsolutePath` — its behavior is pinned in + // / `CanonicalFilePathSchema` — its behavior is pinned in // `@shared/utils/file/__tests__/canonicalize.test.ts`. it('returns a POSIX absolute path unchanged', () => { diff --git a/src/shared/types/file/common.ts b/src/shared/types/file/common.ts index 04663f339b8..36a180c257e 100644 --- a/src/shared/types/file/common.ts +++ b/src/shared/types/file/common.ts @@ -62,22 +62,11 @@ export const FilePathSchema = z export type FilePath = z.infer /** - * A `FilePath` additionally proven to be in canonical form — the - * byte-faithful, lexically-resolved output of `canonicalizeAbsolutePath` - * (segment-resolve + trailing-separator strip + drive-letter upcase), NOT - * Unicode-normalized. This is the form persisted in `file_entry.externalPath` - * and used as the dedup / lookup key. - * - * TS-only phantom brand with a STRING-LITERAL key (not a `unique symbol`): - * `FileEntry.externalPath` flows into preload's inferred `WindowApiType` - * aggregate, where a transitively-embedded `unique symbol` brand triggers - * TS2527/TS4023. The ONLY sanctioned producer is `canonicalizeFilePath()` - * in `@shared/utils/file/canonicalize`; production code obtains it from that - * factory (directly or transitively), never via a bare `as` cast. A - * `CanonicalFilePath` IS a `FilePath`, so it is accepted anywhere a - * `FilePath` is. + * `CanonicalFilePath` — a `FilePath` additionally proven canonical — is defined + * in `@shared/utils/file/canonicalize` (co-located with its schema and the + * `canonicalizeFilePath()` factory, which is the only place that can build both + * the value and its brand). It is not re-exported here. */ -export type CanonicalFilePath = FilePath & { readonly __canonical: 'CanonicalFilePath' } export type Base64String = `data:${string};base64,${string}` export type UrlString = `http://${string}` | `https://${string}` diff --git a/src/shared/types/file/index.ts b/src/shared/types/file/index.ts index 9346c86e03f..2f435fe7ea9 100644 --- a/src/shared/types/file/index.ts +++ b/src/shared/types/file/index.ts @@ -1,6 +1,5 @@ export { type Base64String, - type CanonicalFilePath, type DirectoryEntry, type DirectoryListOptions, FILE_TYPE, diff --git a/src/shared/utils/file/__tests__/canonicalize.test.ts b/src/shared/utils/file/__tests__/canonicalize.test.ts index 58f4d41fca1..6ddeabbb9a8 100644 --- a/src/shared/utils/file/__tests__/canonicalize.test.ts +++ b/src/shared/utils/file/__tests__/canonicalize.test.ts @@ -1,22 +1,31 @@ /** - * Equivalence tests for `canonicalizeAbsolutePath` — the shared, pure-JS - * implementation that backs the FileEntry schema's `externalPath` refine. + * Tests for the canonical layer's public surface: `canonicalizeFilePath` (the + * branding factory), `CanonicalFilePathSchema` (assert-only validation), and + * `isCanonicalFilePath` (the predicate). The canonicalization algorithm itself + * is module-private and is exercised transitively through `canonicalizeFilePath`, + * whose runtime result is exactly the canonicalized string (the brand is a + * compile-time phantom). * - * For inputs that match the host platform, the result must equal what the - * main-side `path.resolve` + trailing-strip pipeline produces (byte-faithful, - * NO Unicode normalization); this keeps the canonicalize-on-write path - * (`canonicalizeFilePath`, applied at the external-path boundary) and the - * schema's `externalPath` canonical-equivalence refine in lockstep. - * Cross-platform cases (Windows-shaped paths processed on POSIX hosts and vice - * versa) are pinned by handcrafted expectations because `path.resolve` is - * host-aware and can't be used as the oracle there. + * For inputs that match the host platform, the factory result must equal what + * the main-side `path.resolve` + trailing-strip pipeline produces + * (byte-faithful, NO Unicode normalization); this keeps the canonicalize-on-write + * path (applied at the external-path boundary) and the schema's canonical- + * equivalence assertion in lockstep. Cross-platform cases (Windows-shaped paths + * processed on POSIX hosts and vice versa) are pinned by handcrafted + * expectations because `path.resolve` is host-aware and can't be the oracle there. */ import path from 'node:path' import { describe, expect, it } from 'vitest' -import { canonicalizeAbsolutePath, canonicalizeFilePath } from '../canonicalize' +import { CanonicalFilePathSchema, canonicalizeFilePath, isCanonicalFilePath } from '../canonicalize' + +// Byte-distinct NFD/NFC forms of ".../qué", written with ASCII \u escapes so the +// literals stay stable across editors/formatters (a precomposed source char +// would make the byte-faithfulness assertion tautological). +const QUE_NFD = '/users/qu\u0065\u0301' // q u e + U+0301 combining acute (NFD) +const QUE_NFC = '/users/qu\u00e9' // q u e-precomposed (NFC) function nodeCanonicalize(raw: string): string { // Byte-faithful: `path.resolve` (segment resolve) + trailing-strip only. @@ -29,75 +38,109 @@ function nodeCanonicalize(raw: string): string { return normalized } -describe('canonicalizeAbsolutePath — POSIX', () => { +describe('canonicalizeFilePath — POSIX', () => { it('rejects null bytes', () => { - expect(() => canonicalizeAbsolutePath('/foo/bar\0/baz')).toThrow(/null byte/i) + expect(() => canonicalizeFilePath('/foo/bar\0/baz')).toThrow(/null byte/i) }) it('rejects non-absolute input', () => { - expect(() => canonicalizeAbsolutePath('foo/bar')).toThrow(/absolute/i) + expect(() => canonicalizeFilePath('foo/bar')).toThrow(/absolute/i) }) it('collapses `.` and `..` segments', () => { - expect(canonicalizeAbsolutePath('/foo/./bar/../baz')).toBe('/foo/baz') + expect(canonicalizeFilePath('/foo/./bar/../baz')).toBe('/foo/baz') }) it('strips trailing separator (except root)', () => { - expect(canonicalizeAbsolutePath('/foo/bar/')).toBe('/foo/bar') - expect(canonicalizeAbsolutePath('/')).toBe('/') + expect(canonicalizeFilePath('/foo/bar/')).toBe('/foo/bar') + expect(canonicalizeFilePath('/')).toBe('/') }) it('collapses repeated separators', () => { - expect(canonicalizeAbsolutePath('/foo//bar')).toBe('/foo/bar') + expect(canonicalizeFilePath('/foo//bar')).toBe('/foo/bar') }) - it('does NOT Unicode-normalize \u2014 returns decomposed (NFD) input byte-faithfully unchanged', () => { - // Canonicalization is byte-faithful: an NFD path stays NFD so it still - // reaches the real file on normalization-sensitive filesystems (Linux - // ext4). ASCII \\u escapes keep the literals stable across tooling. - const nfd = '/users/qu\u0065\u0301' // qu + e + combining acute (NFD) - const nfc = '/users/qu\u00E9' // qu + e-precomposed (NFC) - expect(nfd).not.toBe(nfc) // byte-distinct inputs - expect(canonicalizeAbsolutePath(nfd)).toBe(nfd) // unchanged, NOT folded to NFC + it('does NOT Unicode-normalize — returns decomposed (NFD) input byte-faithfully unchanged', () => { + // An NFD path stays NFD so it still reaches the real file on + // normalization-sensitive filesystems (Linux ext4), never folded to NFC. + expect(QUE_NFD).not.toBe(QUE_NFC) // byte-distinct inputs + expect(canonicalizeFilePath(QUE_NFD)).toBe(QUE_NFD) }) it('matches node:path on the host platform for representative inputs', () => { if (process.platform === 'win32') return // skipped on win32 (host expects \-paths) for (const raw of ['/foo/bar', '/foo/./bar/../baz', '/foo/bar/', '/foo//bar', '/']) { - expect(canonicalizeAbsolutePath(raw)).toBe(nodeCanonicalize(raw)) + expect(canonicalizeFilePath(raw)).toBe(nodeCanonicalize(raw)) } }) }) -describe('canonicalizeAbsolutePath — Windows', () => { +describe('canonicalizeFilePath — Windows', () => { it('uppercases the drive letter and uses backslash separators', () => { - expect(canonicalizeAbsolutePath('c:\\Foo\\Bar')).toBe('C:\\Foo\\Bar') + expect(canonicalizeFilePath('c:\\Foo\\Bar')).toBe('C:\\Foo\\Bar') }) it('treats `/` and `\\` interchangeably as segment separators', () => { - expect(canonicalizeAbsolutePath('C:\\foo/bar\\baz')).toBe('C:\\foo\\bar\\baz') + expect(canonicalizeFilePath('C:\\foo/bar\\baz')).toBe('C:\\foo\\bar\\baz') }) it('collapses `.` and `..` segments', () => { - expect(canonicalizeAbsolutePath('C:\\foo\\.\\bar\\..\\baz')).toBe('C:\\foo\\baz') + expect(canonicalizeFilePath('C:\\foo\\.\\bar\\..\\baz')).toBe('C:\\foo\\baz') }) it('strips trailing separator (except drive root)', () => { - expect(canonicalizeAbsolutePath('C:\\foo\\')).toBe('C:\\foo') - expect(canonicalizeAbsolutePath('C:\\')).toBe('C:\\') + expect(canonicalizeFilePath('C:\\foo\\')).toBe('C:\\foo') + expect(canonicalizeFilePath('C:\\')).toBe('C:\\') }) }) -describe('canonicalizeFilePath (branding factory)', () => { - // The sole sanctioned producer of the CanonicalFilePath brand. At runtime it - // is exactly canonicalizeAbsolutePath + a phantom (compile-time) brand. - it('returns the canonicalized string (same result as canonicalizeAbsolutePath)', () => { - expect(canonicalizeFilePath('/foo/./bar/../baz')).toBe('/foo/baz') - expect(canonicalizeFilePath('/foo/bar/')).toBe('/foo/bar') +describe('CanonicalFilePathSchema (assert-only, no repair)', () => { + // Backs the FileEntry `externalPath` read path: an already-canonical value is + // accepted unchanged; a non-canonical one is REJECTED (never silently + // repaired), so historically non-canonical rows warn-skip rather than mutate + // the lookup/dedup key. + it('accepts an already-canonical path and returns it unchanged', () => { + expect(CanonicalFilePathSchema.parse('/foo/bar')).toBe('/foo/bar') + expect(CanonicalFilePathSchema.parse('C:\\Foo\\Bar')).toBe('C:\\Foo\\Bar') }) - it('throws on non-absolute / null-byte input (delegated to canonicalizeAbsolutePath)', () => { - expect(() => canonicalizeFilePath('foo/bar')).toThrow(/absolute/i) - expect(() => canonicalizeFilePath('/foo/\0bar')).toThrow(/null byte/i) + it('accepts a byte-faithful (NFD) canonical path unchanged', () => { + expect(CanonicalFilePathSchema.parse(QUE_NFD)).toBe(QUE_NFD) + }) + + it('rejects a non-canonical path (unresolved `.` / `..`)', () => { + expect(CanonicalFilePathSchema.safeParse('/foo/./bar').success).toBe(false) + expect(CanonicalFilePathSchema.safeParse('/foo/bar/../baz').success).toBe(false) + }) + + it('rejects a trailing-separator path', () => { + expect(CanonicalFilePathSchema.safeParse('/foo/bar/').success).toBe(false) + }) + + it('rejects a lowercase Windows drive letter (non-canonical)', () => { + expect(CanonicalFilePathSchema.safeParse('c:\\Foo\\Bar').success).toBe(false) + }) + + it('rejects non-absolute / null-byte input', () => { + expect(CanonicalFilePathSchema.safeParse('foo/bar').success).toBe(false) + expect(CanonicalFilePathSchema.safeParse('/foo/\0bar').success).toBe(false) + }) +}) + +describe('isCanonicalFilePath', () => { + it('is true for already-canonical paths', () => { + expect(isCanonicalFilePath('/foo/bar')).toBe(true) + expect(isCanonicalFilePath('C:\\Foo\\Bar')).toBe(true) + }) + + it('is false for non-canonical paths', () => { + expect(isCanonicalFilePath('/foo/./bar')).toBe(false) + expect(isCanonicalFilePath('/foo/bar/')).toBe(false) + expect(isCanonicalFilePath('c:\\Foo\\Bar')).toBe(false) + }) + + it('is false (does not throw) for structurally invalid input', () => { + expect(isCanonicalFilePath('foo/bar')).toBe(false) + expect(isCanonicalFilePath('/foo/\0bar')).toBe(false) }) }) diff --git a/src/shared/utils/file/canonicalize.ts b/src/shared/utils/file/canonicalize.ts index f02ede433a4..a4b5a4e21bc 100644 --- a/src/shared/utils/file/canonicalize.ts +++ b/src/shared/utils/file/canonicalize.ts @@ -1,13 +1,16 @@ /** * Pure-JS canonicalization for absolute filesystem paths. * - * Lives in shared (no `node:*` imports). This module owns two things: the - * canonicalization algorithm (`canonicalizeAbsolutePath`) and the branding - * factory (`canonicalizeFilePath`). `FilePath` (`@shared/types/file/common`) - * is shape-validated only — it does NOT canonicalize on parse. Canonicalization - * is applied explicitly via `canonicalizeFilePath`, which produces the - * `CanonicalFilePath` sub-brand, at the external-path persistence / lookup - * boundary. + * Lives in shared (no `node:*` imports). This module owns the whole canonical + * layer: the (module-private) canonicalization algorithm, the + * `CanonicalFilePath` brand + `CanonicalFilePathSchema`, the + * `isCanonicalFilePath` predicate, and the `canonicalizeFilePath` factory. The + * schema is defined HERE, not in `types/file/common`, because it needs both + * `FilePathSchema` and the algorithm — and only `utils → types` avoids an + * import cycle. `FilePath` (`@shared/types/file/common`) is shape-validated + * only — it does NOT canonicalize on parse. Canonicalization is applied + * explicitly via `canonicalizeFilePath`, which produces the `CanonicalFilePath` + * sub-brand, at the external-path persistence / lookup boundary. * * ## Scope (this function's contract) * @@ -40,9 +43,10 @@ * "Residual normalization discipline"`. */ -import type { CanonicalFilePath } from '@shared/types/file/common' +import { FilePathSchema } from '@shared/types/file/common' +import type * as z from 'zod' -export function canonicalizeAbsolutePath(raw: string): string { +function canonicalizeAbsolutePath(raw: string): string { if (raw.includes('\0')) { throw new Error('canonicalizeAbsolutePath: input contains null byte') } @@ -52,18 +56,60 @@ export function canonicalizeAbsolutePath(raw: string): string { } /** - * The sole sanctioned producer of `CanonicalFilePath`: run the pure-JS - * canonicalization and brand the result. Callers needing a canonical + * True iff `p` is already in byte-faithful canonical form — i.e. canonicalizing + * it is a no-op. Returns `false` (rather than throwing) for structurally + * invalid input (non-absolute, null byte), so it is safe as a Zod refine + * predicate. Backs both `CanonicalFilePathSchema` and the FileEntry + * `externalPath` read-path assertion. + */ +export function isCanonicalFilePath(p: string): boolean { + try { + return p === canonicalizeAbsolutePath(p) + } catch { + return false + } +} + +/** + * Zod schema + brand for `CanonicalFilePath`, mirroring how `FilePathSchema` + * defines `FilePath` (both are `z.infer`-derived from their schema). Reuses + * `FilePathSchema` for the absolute-shape refine, then ASSERTS byte-faithful + * canonical form (via `isCanonicalFilePath`) and brands. + * + * `parse()` is assert-only, NOT repairing: it returns an already-canonical + * input unchanged and REJECTS (ZodError) a non-canonical one. This is the "no + * silent repair" contract the FileEntry `externalPath` read path depends on. + * To canonicalize a raw path, use `canonicalizeFilePath` (which repairs, then + * brands through this schema). + */ +export const CanonicalFilePathSchema = FilePathSchema.refine( + isCanonicalFilePath, + 'must be in byte-faithful canonical form (produce it via canonicalizeFilePath)' +).brand<'CanonicalFilePath'>() + +/** + * A `FilePath` additionally proven canonical — the byte-faithful, lexically + * resolved form (segment-resolve + trailing-strip + Windows drive-upcase), NOT + * Unicode-normalized. This is the form persisted in `file_entry.externalPath` + * and used as the dedup / lookup key. Inferred from `CanonicalFilePathSchema`, + * so its definition style matches `FilePath`. A `CanonicalFilePath` IS a + * `FilePath`, accepted anywhere a `FilePath` is. + */ +export type CanonicalFilePath = z.infer + +/** + * The sole sanctioned producer of `CanonicalFilePath`: canonicalize a raw + * absolute path (byte-faithful lexical resolve — segment-resolve + + * trailing-strip + Windows drive-upcase, NOT Unicode-normalized) and brand the + * result through `CanonicalFilePathSchema`. Callers needing a canonical * lookup/persistence key (external-entry write + `findByExternalPath`) go - * through here instead of `as`-casting into the brand. The produced value is - * the byte-faithful, lexically-resolved form (segment-resolve + trailing-strip - * + Windows drive-upcase) — NOT Unicode-normalized. + * through here. * - * @throws if `input` is not absolute / contains a null byte (delegated to - * `canonicalizeAbsolutePath`). + * @throws if `input` is not absolute / contains a null byte (delegated to the + * canonicalization algorithm, before branding). */ export function canonicalizeFilePath(input: string): CanonicalFilePath { - return canonicalizeAbsolutePath(input) as CanonicalFilePath + return CanonicalFilePathSchema.parse(canonicalizeAbsolutePath(input)) } function canonicalizePosix(raw: string): string { diff --git a/src/shared/utils/file/index.ts b/src/shared/utils/file/index.ts index 99b63a2731a..bc698d47f7e 100644 --- a/src/shared/utils/file/index.ts +++ b/src/shared/utils/file/index.ts @@ -1,4 +1,9 @@ -export { canonicalizeAbsolutePath } from './canonicalize' +export { + type CanonicalFilePath, + CanonicalFilePathSchema, + canonicalizeFilePath, + isCanonicalFilePath +} from './canonicalize' export { audioExts, codeLangExts, From 32215e61172399a6a56616877a04c96bd792f745 Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sun, 5 Jul 2026 20:37:45 +0800 Subject: [PATCH 16/22] chore(eslint): extend no-as-filepath guard to CanonicalFilePath `CanonicalFilePath` is now the load-bearing dedup-key brand, but only `as FilePath` was lint-guarded. A forged `as CanonicalFilePath` silently bypasses canonicalization and can write a ghost-duplicate key. Extend the rule to report `as CanonicalFilePath` too (distinct message pointing at canonicalizeFilePath()). After the schema refactor there are zero production `as CanonicalFilePath` casts, so no eslint-disable exemptions are needed; test fixtures remain exempt via the existing __tests__/*.test ignores. Addresses self-review R18. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- eslint.config.mjs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 4c3cd7f5da8..1fc5510cfa6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -212,9 +212,13 @@ export default defineConfig([ ] } }, - // FilePath brand integrity — `as FilePath` forges the brand, skipping - // FilePathSchema's shape validation. Production code must build a FilePath - // via FilePathSchema.parse(value). + // Path brand integrity — `as FilePath` / `as CanonicalFilePath` forge the + // brands, skipping the validation each asserts. `FilePath` asserts shape + // validation (build via FilePathSchema.parse); `CanonicalFilePath` asserts + // the byte-faithful lexical form that backs the external-path dedup key + // (build via the canonicalizeFilePath() factory). A forged + // `as CanonicalFilePath` silently bypasses canonicalization and can write a + // ghost-duplicate key, so the stronger brand is guarded too. // Exemptions: test fixtures; and two deliberate raw-OS-path regimes — the // directory watcher (watcher/**) and the tree builder (tree/**) hold raw // chokidar/OS event paths that are compared byte-for-byte against event @@ -237,24 +241,25 @@ export default defineConfig([ type: 'problem', docs: { description: - 'Disallow `as FilePath` casts. FilePath is a Zod brand asserting shape validation (absolute path, no null bytes); forging it bypasses that validation. Construct via FilePathSchema.parse().', + 'Disallow `as FilePath` / `as CanonicalFilePath` casts. Both are Zod-derived brands: FilePath asserts shape validation (absolute path, no null bytes), CanonicalFilePath additionally asserts the byte-faithful lexical form backing the dedup key. Forging either bypasses its validation. Construct via FilePathSchema.parse() / canonicalizeFilePath().', recommended: true }, messages: { noAsFilePath: - '`as FilePath` forges the brand, skipping FilePathSchema\'s absolute-path validation. Build it with FilePathSchema.parse(value) instead. If this is a deliberate raw-path regime, move it under an exempted path or justify with an eslint-disable + reason.' + '`as FilePath` forges the brand, skipping FilePathSchema\'s absolute-path validation. Build it with FilePathSchema.parse(value) instead. If this is a deliberate raw-path regime, move it under an exempted path or justify with an eslint-disable + reason.', + noAsCanonicalFilePath: + '`as CanonicalFilePath` forges the brand, skipping canonicalization — a non-canonical value silently becomes a ghost-duplicate dedup key. Build it with canonicalizeFilePath(value) instead, or justify the sanctioned producer with an eslint-disable + reason.' } }, create(context) { return { TSAsExpression(node) { const ann = node.typeAnnotation - if ( - ann?.type === 'TSTypeReference' && - ann.typeName?.type === 'Identifier' && - ann.typeName.name === 'FilePath' - ) { + if (ann?.type !== 'TSTypeReference' || ann.typeName?.type !== 'Identifier') return + if (ann.typeName.name === 'FilePath') { context.report({ node, messageId: 'noAsFilePath' }) + } else if (ann.typeName.name === 'CanonicalFilePath') { + context.report({ node, messageId: 'noAsCanonicalFilePath' }) } } } From 6b1106119b903cd3277583a8d97c43eb27674006 Mon Sep 17 00:00:00 2001 From: eurfelux Date: Sun, 5 Jul 2026 22:00:18 +0800 Subject: [PATCH 17/22] fix(composer): align attachment-build failure with upstream abort-on-failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebasing onto main pulled in #16732, which handles a failed edited-message resend by aborting + toasting: buildFilePartsForAttachments uses Promise.all, so any attachment failure rejects the batch, and it added a ChatComposer test asserting forkAndResend is NOT called on failure. This branch's earlier R3 fix took the opposite stance (Promise.allSettled: skip the bad attachment, send the rest), so the two collided during the rebase. Defer to the landed upstream behavior — failing loudly beats silently dropping a file the user attached, and R3's actual concern (an uncaught rejection from the path parse) is already covered by the send-flow try/catch #16732 wraps it in. - buildFilePartsForAttachments: Promise.allSettled -> Promise.all (propagate). - Keep FilePathSchema.parse(attachment.path) for validation instead of upstream's `as FilePath` cast (which the branch's no-as-filepath lint forbids). - buildFileParts.test.ts: the "skips the bad attachment" test becomes an "aborts the batch on a non-absolute path" (rejects) test. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: eurfelux --- .../file/__tests__/buildFileParts.test.ts | 25 ++++------------- src/renderer/utils/file/buildFileParts.ts | 27 +++++-------------- 2 files changed, 11 insertions(+), 41 deletions(-) diff --git a/src/renderer/utils/file/__tests__/buildFileParts.test.ts b/src/renderer/utils/file/__tests__/buildFileParts.test.ts index 230a6fd15aa..aebdeb90247 100644 --- a/src/renderer/utils/file/__tests__/buildFileParts.test.ts +++ b/src/renderer/utils/file/__tests__/buildFileParts.test.ts @@ -1,6 +1,5 @@ import { FILE_TYPE } from '@renderer/types/file' import type { ComposerAttachment } from '@renderer/utils/message/composerAttachment' -import { mockRendererLoggerService } from '@test-mocks/RendererLoggerService' import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildFilePartsForAttachments } from '../buildFileParts' @@ -69,27 +68,13 @@ describe('buildFilePartsForAttachments', () => { expect(part.url).toBe('file:///p/fe-3.pdf') }) - it('isolates a non-absolute path: skips the bad attachment, logs a warning, and still returns the rest', async () => { - const warnSpy = vi.spyOn(mockRendererLoggerService, 'warn').mockImplementation(() => {}) - + it('rejects the whole batch when an attachment path is non-absolute (send flow surfaces it)', async () => { + // A non-absolute path fails `FilePathSchema.parse`, which rejects the + // Promise.all batch rather than silently dropping the attachment — the + // caller's send-flow try/catch turns that into a toast + keep-editing. const goodAttachment = attachment() const badAttachment = attachment({ path: 'not-absolute.png', name: 'bad.png' }) - const parts = await buildFilePartsForAttachments([goodAttachment, badAttachment]) - - expect(parts).toHaveLength(1) - expect(parts[0]).toEqual({ - type: 'file', - url: 'file:///p/fe-1.png', - mediaType: 'image/png', - filename: 'image.png', - providerMetadata: { cherry: { fileEntryId: 'fe-1' } } - }) - expect(warnSpy).toHaveBeenCalledWith( - 'failed to build file part for attachment, skipping it', - expect.objectContaining({ path: 'not-absolute.png', name: 'bad.png' }) - ) - - warnSpy.mockRestore() + await expect(buildFilePartsForAttachments([goodAttachment, badAttachment])).rejects.toThrow(/absolute/i) }) }) diff --git a/src/renderer/utils/file/buildFileParts.ts b/src/renderer/utils/file/buildFileParts.ts index a974598f101..ca53fe504e6 100644 --- a/src/renderer/utils/file/buildFileParts.ts +++ b/src/renderer/utils/file/buildFileParts.ts @@ -11,26 +11,26 @@ * for the accessor + Zod. */ -import { loggerService } from '@logger' import type { ComposerAttachment } from '@renderer/utils/message/composerAttachment' import type { FileUIPart } from '@shared/data/types/message' import { withCherryMeta } from '@shared/data/types/uiParts' import { FilePathSchema } from '@shared/types/file' import { createFilePathHandle } from '@shared/utils/file/handle' -const logger = loggerService.withContext('buildFileParts') - /** * For each `ComposerAttachment` (with an absolute `path`), create a v2 internal * FileEntry (Cherry copies the bytes into its own storage) and return a * `FileUIPart` that carries the new `fileEntryId` plus a `file://` URL * pointing at the freshly-copied physical file. * - * A single attachment failing (e.g. a legacy/non-absolute `path`) is isolated: - * it is logged and skipped rather than rejecting the whole batch. + * `attachment.path` is validated through `FilePathSchema.parse` (not an `as` + * cast). Any failure — a non-absolute / malformed path, or a rejected + * `createInternalEntry` — rejects the whole batch, so the caller's send-flow + * try/catch surfaces it (toast + keep editing) rather than silently dropping a + * file the user attached. */ export async function buildFilePartsForAttachments(attachments: ComposerAttachment[]): Promise { - const results = await Promise.allSettled( + return Promise.all( attachments.map(async (attachment) => { const entry = await window.api.file.createInternalEntry({ source: 'path', @@ -47,19 +47,4 @@ export async function buildFilePartsForAttachments(attachments: ComposerAttachme return withCherryMeta(basePart, { fileEntryId: entry.id }) }) ) - - const parts: FileUIPart[] = [] - results.forEach((result, index) => { - if (result.status === 'fulfilled') { - parts.push(result.value) - return - } - const attachment = attachments[index] - logger.warn('failed to build file part for attachment, skipping it', { - path: attachment.path, - name: attachment.name, - error: result.reason - }) - }) - return parts } From d3f40d4c85ed97c74184a215975231ba9269cbcc Mon Sep 17 00:00:00 2001 From: icarus Date: Mon, 6 Jul 2026 16:41:28 +0800 Subject: [PATCH 18/22] fix(shared): type-safe dirnameSimple with FilePath output --- src/shared/utils/file/url.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/shared/utils/file/url.ts b/src/shared/utils/file/url.ts index 8cd127fde93..cbfb63bdc3f 100644 --- a/src/shared/utils/file/url.ts +++ b/src/shared/utils/file/url.ts @@ -141,7 +141,7 @@ export function isDangerExt(ext: string | null | undefined): boolean { // ─── Path formatting ─── /** - * Cross-platform dirname on a plain string — no `node:path` dependency, so it + * Cross-platform dirname on a `FilePath` — no `node:path` dependency, so it * works in renderer bundles. Treats both `/` and `\` as separators. * * `sepIdx === 0` is the POSIX-root case (`/payload.exe`): degrade to `'/'` so @@ -154,13 +154,13 @@ export function isDangerExt(ext: string | null | undefined): boolean { * separator to recognize a drive letter as a root). Keep the separator so * the result stays a valid, canonical drive root (`C:\`). */ -function dirnameSimple(absolutePath: string): string { +function dirnameSimple(absolutePath: FilePath): FilePath { const sepIdx = Math.max(absolutePath.lastIndexOf('/'), absolutePath.lastIndexOf('\\')) if (sepIdx > 0) { const dir = absolutePath.slice(0, sepIdx) - return /^[A-Za-z]:$/.test(dir) ? absolutePath.slice(0, sepIdx + 1) : dir + return FilePathSchema.parse(/^[A-Za-z]:$/.test(dir) ? absolutePath.slice(0, sepIdx + 1) : dir) } - if (sepIdx === 0) return '/' + if (sepIdx === 0) return FilePathSchema.parse('/') return absolutePath } @@ -233,5 +233,5 @@ export function fileUrlToPath(fileUrl: FileUrlString | URL): string { */ export function toSafeFileUrl(absolutePath: FilePath, ext: string | null): FileUrlString { const effectivePath = isDangerExt(ext) ? dirnameSimple(absolutePath) : absolutePath - return toFileUrl(FilePathSchema.parse(effectivePath)) + return toFileUrl(effectivePath) } From d06b17e982140fcf616fac94f50a55b2a37fc279 Mon Sep 17 00:00:00 2001 From: icarus Date: Mon, 6 Jul 2026 17:00:56 +0800 Subject: [PATCH 19/22] refactor(file): validate FilePath at DirectoryWatcher chokidar boundary Signed-off-by: icarus --- src/main/services/file/watcher.ts | 55 +++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/src/main/services/file/watcher.ts b/src/main/services/file/watcher.ts index 0b7c4b00bcf..288060d48e6 100644 --- a/src/main/services/file/watcher.ts +++ b/src/main/services/file/watcher.ts @@ -37,7 +37,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { Emitter } from '@main/core/lifecycle' -import type { FilePath } from '@shared/types/file' +import { type FilePath, FilePathSchema } from '@shared/types/file' import { type FSWatcher, watch as chokidarWatch } from 'chokidar' import { danglingCache } from './danglingCache' @@ -66,6 +66,12 @@ export type WatcherEvent = export type WatcherListener = (event: WatcherEvent) => void +/** Internal shape before FilePath validation. */ +type RawWatcherPathEvent = { + readonly kind: 'add' | 'addDir' | 'unlink' | 'unlinkDir' | 'change' + readonly path: string +} + export interface DirectoryWatcher { /** * Subscribe to normalized FS events. Returns an unsubscribe function. @@ -116,18 +122,26 @@ class DirectoryWatcherImpl implements DirectoryWatcher { const depth = recursive ? this.opts.maxDepth : 0 const fsw = chokidarWatch(this.root, { - ignored: userIgnore ? [builtinIgnore, (p) => userIgnore(p as FilePath)] : [builtinIgnore], + ignored: userIgnore + ? [ + builtinIgnore, + (p) => { + const parsed = this.parseChokidarPath(p) + return !parsed || userIgnore(parsed) + } + ] + : [builtinIgnore], ignoreInitial: true, depth, awaitWriteFinish: stability > 0 ? { stabilityThreshold: stability, pollInterval: 100 } : false, usePolling }) - fsw.on('add', (p) => this.handle({ kind: 'add', path: p as FilePath })) - fsw.on('addDir', (p) => this.handle({ kind: 'addDir', path: p as FilePath })) - fsw.on('change', (p) => this.handle({ kind: 'change', path: p as FilePath })) - fsw.on('unlink', (p) => this.handle({ kind: 'unlink', path: p as FilePath })) - fsw.on('unlinkDir', (p) => this.handle({ kind: 'unlinkDir', path: p as FilePath })) + fsw.on('add', (p) => this.handle({ kind: 'add', path: p })) + fsw.on('addDir', (p) => this.handle({ kind: 'addDir', path: p })) + fsw.on('change', (p) => this.handle({ kind: 'change', path: p })) + fsw.on('unlink', (p) => this.handle({ kind: 'unlink', path: p })) + fsw.on('unlinkDir', (p) => this.handle({ kind: 'unlinkDir', path: p })) fsw.on('ready', () => this.emitter.fire({ kind: 'ready' })) fsw.on('error', (err) => this.handleError(err as Error)) @@ -155,12 +169,28 @@ class DirectoryWatcherImpl implements DirectoryWatcher { } } + /** + * Validate a path emitted by chokidar. chokidar's public types only promise + * `string`, but in our configuration (absolute root, no `cwd`) the emitted + * paths should always satisfy `FilePathSchema`. This helper turns that + * assumption into a checked invariant and returns `null` for any unexpected + * value so callers can drop or ignore it safely. + */ + private parseChokidarPath(raw: string): FilePath | null { + const result = FilePathSchema.safeParse(raw) + return result.success ? result.data : null + } + /** * Forward chokidar's `add` / `unlink` / `change` events to subscribers AND * mirror presence transitions into DanglingCache. `change` is intentionally * not mirrored — the file is still present; only mtime drift, which the * cache doesn't track. * + * FilePath validation happens here, at the boundary between chokidar's + * `string` events and the FilePath-typed consumers (`DanglingCache`, + * `WatcherEvent` subscribers). Invalid paths are logged and dropped. + * * DanglingCache's reverse index is keyed **byte-faithful**: it is populated * by `ensureExternalEntry`, whose `externalPath` is stored exactly as the OS * handed it (byte-faithful lexical form, no NFC). So the watcher matches @@ -177,13 +207,18 @@ class DirectoryWatcherImpl implements DirectoryWatcher { * `docs/references/file/file-manager-architecture.md §11.3 "Watcher * Auto-Wiring"`. */ - private handle(ev: Extract): void { + private handle(ev: RawWatcherPathEvent): void { if (this.closed) return + const path = this.parseChokidarPath(ev.path) + if (!path) { + logger.warn('chokidar emitted a path that does not satisfy FilePathSchema', { path: ev.path }) + return + } if (ev.kind === 'add' || ev.kind === 'unlink') { const presence = ev.kind === 'add' ? 'present' : 'missing' - danglingCache.onFsEvent(ev.path, presence, 'watcher') + danglingCache.onFsEvent(path, presence, 'watcher') } - this.emitter.fire(ev) + this.emitter.fire({ kind: ev.kind, path }) } onEvent(listener: WatcherListener): () => void { From 1c1d8d4e7578886e4c977275b15190faad447975 Mon Sep 17 00:00:00 2001 From: icarus Date: Tue, 7 Jul 2026 11:20:39 +0900 Subject: [PATCH 20/22] docs: add todo comment for Base64String and UrlString --- src/shared/types/file/common.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/shared/types/file/common.ts b/src/shared/types/file/common.ts index 36a180c257e..d4852a719d2 100644 --- a/src/shared/types/file/common.ts +++ b/src/shared/types/file/common.ts @@ -67,6 +67,8 @@ export type FilePath = z.infer * `canonicalizeFilePath()` factory, which is the only place that can build both * the value and its brand). It is not re-exported here. */ + +// TODO: Add schema for them export type Base64String = `data:${string};base64,${string}` export type UrlString = `http://${string}` | `https://${string}` From a2335bf790a0688680287c299adad2a3b9fc3565 Mon Sep 17 00:00:00 2001 From: icarus Date: Thu, 9 Jul 2026 07:52:56 +0900 Subject: [PATCH 21/22] chore(file-types): clean up merge-conflict residue - Delete src/shared/types/file/handle.ts (orphaned duplicate, zero references). - Update eslint watcher glob from watcher/** to watcher.ts (watcher is now a single file). Signed-off-by: icarus --- eslint.config.mjs | 2 +- src/shared/types/file/handle.ts | 60 --------------------------------- 2 files changed, 1 insertion(+), 61 deletions(-) delete mode 100644 src/shared/types/file/handle.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 224bdd80237..5c531d96e0d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -450,7 +450,7 @@ export default defineConfig([ { files: ['src/**/*.{ts,tsx}'], ignores: [ - 'src/main/services/file/watcher/**', + 'src/main/services/file/watcher.ts', 'src/main/services/file/tree/**', 'src/**/__tests__/**', 'src/**/__mocks__/**', diff --git a/src/shared/types/file/handle.ts b/src/shared/types/file/handle.ts deleted file mode 100644 index 7b5dc9c704e..00000000000 --- a/src/shared/types/file/handle.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * FileHandle — unified reference to any file accessible by Cherry. - * - * A handle is a **call-site choice of reference form**, not a statement about - * the file's ownership or registration status: - * - `FileEntryHandle` carries a `FileEntryId` — the call goes through the - * entry system (FileManager, versionCache, DanglingCache, …). - * - `FilePathHandle` carries an absolute `FilePath` — the call bypasses the - * entry system and hits `@main/utils/file/*` directly. - * - * The same physical file can be referenced by either form. In particular, an - * external file that has a FileEntry can also be reached via a `FilePathHandle` - * (with different side-effect semantics: no DanglingCache update, no version - * cache lookup, no identity-tracked ops). Picking a form is the caller's - * decision, driven by which subsystem they want in the loop. - * - * Distinct from `FileRef` (the association shape linking business entities - * like chat_message to FileEntry). - * - * The runtime factories and type guards (`createFilePathHandle`, - * `isFilePathHandle`, …) live in `@shared/utils/file` — this module owns only - * the handle shapes and their IPC-boundary schemas. - */ - -import { type FileEntryId, FileEntryIdSchema } from '@shared/data/types/file' -import * as z from 'zod' - -import { type FilePath, FilePathSchema } from './common' - -export type FileEntryHandle = { - readonly kind: 'entry' - readonly entryId: FileEntryId -} - -export type FilePathHandle = { - readonly kind: 'path' - readonly path: FilePath -} - -export type FileHandle = FileEntryHandle | FilePathHandle - -/** - * Zod schemas for `FileHandle`, used to validate IPC payloads at the main-process - * boundary. The runtime factories `createFileEntryHandle` / `createFilePathHandle` - * (in `@shared/utils/file`) are for in-process construction; these schemas are - * the gate for untrusted input crossing the IPC seam. - */ -export const FileEntryHandleSchema = z.strictObject({ - kind: z.literal('entry'), - entryId: FileEntryIdSchema -}) - -export const FilePathHandleSchema = z.strictObject({ - kind: z.literal('path'), - path: FilePathSchema -}) - -export const FileHandleSchema = z.discriminatedUnion('kind', [FileEntryHandleSchema, FilePathHandleSchema]) -// TODO: 1. Wire schema and types, so no as cast needed -// TODO: 2. Add brand for FileHandle since factory function has been used From 59301a7a887c684c360fac6e2b5ca84cd10f60fd Mon Sep 17 00:00:00 2001 From: icarus Date: Thu, 9 Jul 2026 07:55:30 +0900 Subject: [PATCH 22/22] chore(lint): remove watcher exemption from no-as-filepath rule watcher.ts no longer uses as FilePath casts after earlier cleanup, so the filepath-brand/no-as-filepath exemption is unnecessary. Signed-off-by: icarus --- eslint.config.mjs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 5c531d96e0d..a8124bfa1f8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -442,15 +442,13 @@ export default defineConfig([ // (build via the canonicalizeFilePath() factory). A forged // `as CanonicalFilePath` silently bypasses canonicalization and can write a // ghost-duplicate key, so the stronger brand is guarded too. - // Exemptions: test fixtures; and two deliberate raw-OS-path regimes — the - // directory watcher (watcher/**) and the tree builder (tree/**) hold raw - // chokidar/OS event paths that are compared byte-for-byte against event - // paths and are trusted as-is, so they `as FilePath` rather than routing - // through validation. + // Exemptions: test fixtures; and one deliberate raw-OS-path regime — the + // tree builder (tree/**) holds raw chokidar/OS event paths that are compared + // byte-for-byte against event paths and are trusted as-is, so they `as FilePath` + // rather than routing through validation. { files: ['src/**/*.{ts,tsx}'], ignores: [ - 'src/main/services/file/watcher.ts', 'src/main/services/file/tree/**', 'src/**/__tests__/**', 'src/**/__mocks__/**',