Files
gitmost/packages/git-sync/test/path-guard.test.ts
T
claude code agent 227 24b903aaf3 build(git-sync): land the @docmost/git-sync package into develop, code-only (#326 step 1 / PR-A)
The git-sync converter + engine source lived only on the #119 branch; develop
had just the dead compiled build/. Bring the whole package (src + ~700 tests)
onto develop under CI, with NO consumer wired — git-sync stays fully inert in
develop (nothing in apps/server imports it), so runtime behavior is unchanged.
This unblocks #293 (extract the shared converter package from the landed source)
and lets #119's functionality land LAST, already writing the canonical format
(per the #326 landing order).

- packages/git-sync: src (lib converter + engine) + test corpus + configs.
- Remove develop's dead committed packages/git-sync/build/; gitignore it
  (built in CI/Docker via pnpm build, never committed — no src/build drift).
- pnpm-lock.yaml: add the @docmost/git-sync importer (a missing workspace
  package in the lock is a CI blocker). `pnpm install --frozen-lockfile` passes.
- NO server integration / loader / Dockerfile runtime changes (those come with
  #119 at step 6).

Verified: tsc clean; vitest 711 passed | 1 expected-fail, 0 failures, 0 type
errors; pnpm --frozen-lockfile EXIT 0; apps/server has no git-sync import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 06:21:41 +03:00

111 lines
4.1 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import {
assertVaultPathSafe,
isWithinRoot,
VaultPathUnsafeError,
type PathGuardIo,
} from "../src/engine/path-guard";
const VAULT = "/srv/git-sync/space-1";
/**
* Build a fake PathGuardIo from a model of the filesystem:
* - `symlinks`: absolute paths that ARE symlinks (lstat -> isSymbolicLink).
* - `existing`: absolute paths that EXIST (anything not listed is ENOENT/null).
* The vault root is always treated as existing.
* - `realpaths`: optional realpath overrides (default: identity for existing).
*/
function fakeIo(model: {
symlinks?: string[];
existing?: string[];
realpaths?: Record<string, string>;
}): PathGuardIo {
const symlinks = new Set(model.symlinks ?? []);
const existing = new Set([VAULT, ...(model.existing ?? []), ...symlinks]);
return {
lstat: vi.fn(async (p: string) =>
existing.has(p) ? { isSymbolicLink: symlinks.has(p) } : null,
),
realpath: vi.fn(async (p: string) =>
existing.has(p) ? (model.realpaths?.[p] ?? p) : null,
),
};
}
describe("isWithinRoot", () => {
it("accepts the root itself and nested paths", () => {
expect(isWithinRoot(VAULT, VAULT)).toBe(true);
expect(isWithinRoot(VAULT, `${VAULT}/a/b.md`)).toBe(true);
});
it("rejects siblings, ancestors and `..` traversal", () => {
expect(isWithinRoot(VAULT, "/srv/git-sync/space-2/x.md")).toBe(false);
expect(isWithinRoot(VAULT, "/srv/git-sync")).toBe(false);
expect(isWithinRoot(VAULT, `${VAULT}/../space-2/x.md`)).toBe(false);
expect(isWithinRoot(VAULT, "/etc/passwd")).toBe(false);
});
});
describe("assertVaultPathSafe", () => {
it("allows a normal nested file with no symlinks on its chain", async () => {
const io = fakeIo({ existing: [`${VAULT}/Folder`, `${VAULT}/Folder/Page.md`] });
await expect(
assertVaultPathSafe(io, VAULT, `${VAULT}/Folder/Page.md`),
).resolves.toBeUndefined();
});
it("allows a NOT-YET-EXISTING leaf (the normal write/mkdir case)", async () => {
// Folder exists, the .md does not yet — the walk stops at the absent leaf.
const io = fakeIo({ existing: [`${VAULT}/Folder`] });
await expect(
assertVaultPathSafe(io, VAULT, `${VAULT}/Folder/New.md`),
).resolves.toBeUndefined();
});
it("rejects a TARGET that is itself a symlink (the leak.md -> /etc/passwd attack)", async () => {
const io = fakeIo({ symlinks: [`${VAULT}/leak.md`] });
await expect(
assertVaultPathSafe(io, VAULT, `${VAULT}/leak.md`),
).rejects.toBeInstanceOf(VaultPathUnsafeError);
await expect(
assertVaultPathSafe(io, VAULT, `${VAULT}/leak.md`),
).rejects.toMatchObject({ reason: "symlink" });
});
it("rejects a path whose ANCESTOR directory is a symlink (write-outside-vault primitive)", async () => {
// `escape` is a symlinked dir; writing `escape/x.md` would land outside.
const io = fakeIo({
symlinks: [`${VAULT}/escape`],
existing: [`${VAULT}/escape/x.md`],
});
await expect(
assertVaultPathSafe(io, VAULT, `${VAULT}/escape/x.md`),
).rejects.toMatchObject({ reason: "symlink" });
});
it("rejects a `..` traversal lexically, before any IO", async () => {
const io = fakeIo({});
await expect(
assertVaultPathSafe(io, VAULT, `${VAULT}/../space-2/steal.md`),
).rejects.toMatchObject({ reason: "escape" });
expect(io.lstat).not.toHaveBeenCalled();
});
it("rejects when the deepest existing ancestor's realpath escapes the vault", async () => {
// No symlink flagged by lstat (e.g. the data dir was relocated under a link
// the lexical/lstat checks below the root cannot see), but realpath resolves
// the existing ancestor outside the vault's realpath.
const io = fakeIo({
existing: [`${VAULT}/sub`],
realpaths: { [VAULT]: VAULT, [`${VAULT}/sub`]: "/elsewhere/sub" },
});
await expect(
assertVaultPathSafe(io, VAULT, `${VAULT}/sub/page.md`),
).rejects.toMatchObject({ reason: "escape" });
});
it("allows the vault root path itself", async () => {
const io = fakeIo({});
await expect(assertVaultPathSafe(io, VAULT, VAULT)).resolves.toBeUndefined();
});
});