Skip to content

Brand FilePath with NFC guarantee, unify with CanonicalExternalPath / CanonicalFilePath #15404

Description

@EurFelux

Background

The repository currently maintains three overlapping types for filesystem paths in packages/shared/file/types/common.ts and packages/shared/data/types/file/fileEntry.ts:

Type Definition What it guarantees
FilePath Template literal: `/${string}` | `${string}:\\${string}` Pattern only — TypeScript hint, no runtime check
CanonicalExternalPath Phantom brand: string & { [brand]: 'CanonicalExternalPath' } Reached via canonicalizeExternalPath() (NFC + resolve + strip sep + reject null bytes)
CanonicalFilePath FilePath & CanonicalExternalPath Both above

canonicalizeExternalPath does four things, all pure JS, no filesystem IO:

  1. Reject null bytes
  2. path.resolve(raw) — make absolute, collapse ./ ../
  3. Unicode NFC normalize
  4. Strip trailing separator

The "main-only" framing in current JSDoc (packages/shared/file/types/ipc.ts:117-152) was about the call site (renderer has no need to canonicalize before sending to IPC), not about the function itself.

There is already a TODO in the codebase recognizing the overlap:

// packages/shared/data/types/file/fileEntry.ts:128
// TODO: AbsolutePathSchema and FilePath (@shared/file/types/common) are semantically
// identical — co-locate them in a single file and derive one from the other.

This issue acts on that TODO.

Problem

The FilePath template literal type is a lie of omission. It promises only "looks like an absolute path"; it does not promise NFC, does not reject null bytes, does not strip trailing separators. A FilePath value can carry every form of latent inconsistency that breaks string equality:

  • Native dialogs on macOS APFS return NFD-decomposed paths. The same visual café from a café-form dialog return will fail === against an NFC-form café literal
  • Renderer code uses Array.includes(path) / currentDirectories.some(d => d.path === selected) for dedup against in-state paths (see AgentModal:226, AccessibleDirsSetting:37, BasicSection:83, AddKnowledgeItemDialog:83)
  • Without NFC, every such call is a silent ghost-duplicate waiting to happen

Meanwhile CanonicalExternalPath already provides exactly the brand we need, but it sits behind a name and a JSDoc that frame it as "DB-write-side only." The renderer ends up with the pattern-only FilePath and is on its own for normalization.

The asymmetry has no design justification anymore. Once canonicalizeExternalPath is recognized as a pure function (not main-only), the brand can live everywhere paths live.

Separately, the current AbsolutePathSchema JSDoc claims "Zod cannot enforce this shape at the schema level" — this is not accurate. A Zod transform can absolutely perform NFC + resolve + strip; the schema was simply never written to do so.

Proposal

Single brand, named FilePath, produced by upgrading the existing AbsolutePathSchema — not a new factory function. The Zod schema is already the project-idiomatic way to validate-and-transform; reusing it keeps the type and its construction discipline in one place.

Rebrand FilePath

// packages/shared/file/types/common.ts
declare const filePathBrand: unique symbol
export type FilePath = string & { readonly [filePathBrand]: 'FilePath' }

The template literal pattern is dropped (it was a weak hint, no real guard). The phantom brand is the only contract.

Rename, relocate, and expand the schema

AbsolutePathSchemaFilePathSchema, moved to packages/shared/file/types/common.ts (alongside the FilePath type it produces). The transform takes on the real normalization work that used to live in canonicalizeExternalPath:

// packages/shared/file/types/common.ts
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 absolute')
  .transform((v): FilePath => {
    const resolved = path.resolve(v)
    return resolved.normalize('NFC').replace(/[/\\]+$/, '') as FilePath
  })

FilePathSchema.parse(raw) is the only authorized brand entry point. safeParse for callers that want structured error handling.

Type and function removals

  • FilePath (template literal) — replaced by the new brand definition. Name reused, semantics upgraded
  • CanonicalExternalPath — completely subsumed by the new FilePath. Delete
  • CanonicalFilePath — same, delete
  • canonicalizeExternalPath / canonicalizeAbsolutePath functions — delete. Their work is absorbed into FilePathSchema.transform

Entry point updates

All sites that today produce paths via as FilePath or as CanonicalExternalPath cast must route through FilePathSchema.parse():

  • File / folder picker handlers in FileManager.openSelectDialog / openSaveDialog
  • IPC receiver boundaries that accept raw strings from renderer
  • DB read paths where externalPath is fetched as string and re-typed
  • String-literal path construction in production code (rare; should be flagged for review)

ensureExternalEntry drops its internal canonicalize step since IPC schema parse on EnsureExternalEntryIpcParams already canonicalizes.

IPC boundary handling

The brand is a phantom — IPC serialization drops it. This is unchanged from current CanonicalExternalPath behavior; the receiver re-asserts via as FilePath at the trusted boundary, backed by the sender's contract.

Scope

Current usage in the codebase:

Symbol Files Notes
FilePath 105 Most are passive consumers — type appears in signatures, values flow through unchanged
as FilePath cast 214 Each requires audit: legitimate brand entry, or accidental escape hatch
CanonicalExternalPath 9 Concentrated around file_entry.externalPath write path — small surface
CanonicalFilePath 3 Recently introduced; full sweep is feasible
AbsolutePathSchema ~5 Renamed in this PR

Risks

  • IPC brand erasure: same as current CanonicalExternalPath; no regression
  • Test fixtures with literal as FilePath casts: some literals are NFD or have trailing separators that pass the template-literal check today but would fail the brand factory. Each must be updated to call FilePathSchema.parse()
  • Performance: FilePathSchema.parse() adds NFC normalize + resolve + strip on every cast site. Micro-cost per call; pre-call cache not needed at this scale
  • path.resolve semantics: resolving against process.cwd() differs between main and renderer. Spec must require absolute input (schema refines reject relative) — keeps semantics process-independent

Open design questions

  1. Should FilePathSchema accept FilePath as a no-op pass-through? Useful when receiving values of unknown brand-provenance in untyped contexts. Zod schemas are naturally idempotent on already-normalized inputs (NFC.normalize is a no-op on NFC, trailing-sep strip is a no-op on stripped paths) — but the cost is non-zero. Default: yes, accept (idempotency is natural)
  2. What about file:// URLs? Currently rejected by AbsolutePathSchema. Behavior unchanged — FilePathSchema rejects them
  3. CI lint to forbid as FilePath outside tests? An oxlint rule could prevent regression. Default: yes, scoped to production code

Discovery context

This issue was surfaced during the legacyFile renderer consumer audit (v2-refactor-temp/docs/file-manager/legacyfile-renderer-consumer-audit.md §5.2) while reviewing selectFolder consumers. The audit found:

  • selectFolder returns string | null, missing the FilePath type cue (separate, smaller fix already in eurfelux/v2/file-ipcopenSelectDialog / openSaveDialog IPC signatures updated to return FilePath)
  • Four consumers (AgentModal, AccessibleDirsSetting, BasicSection, AddKnowledgeItemDialog) use the dialog return value as a dedup key via Array.includes / .some(d => d.path === ...) — silently broken under NFD on macOS APFS
  • Fixing it ad-hoc (NFC helper in the dialog handler) was rejected as a patch; the right fix is at the type level

Affected documents

  • packages/shared/file/types/common.ts — new FilePath brand definition, host the relocated FilePathSchema
  • packages/shared/data/types/file/fileEntry.tsAbsolutePathSchema relocation + CanonicalExternalPath / CanonicalFilePath removal
  • src/main/services/file/utils/pathResolver.tscanonicalizeExternalPath / canonicalizeAbsolutePath removal
  • packages/shared/file/types/ipc.ts:117-152 — JSDoc on EnsureExternalEntryIpcParams (the "asymmetry is deliberate" rationale needs rewriting)
  • v2-refactor-temp/docs/file-manager/file-arch-problems-response.md — brand layering discussion
  • v2-refactor-temp/docs/file-manager/rfc-file-manager.md — type contract

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions