fix(#4652): a todo name must be a basename — containment alone cannot say that
The verification checkpoint caught a real design gap, not a flaky test. Confining sourcePath/targetPath within todosRoot correctly rejects `../../escaped`, which leaves the root. It does NOT reject these, because they all land inside it: ../sibling.md -> <todosRoot>/sibling.md escapes pending/, not the root a/../../b.md -> <todosRoot>/b.md same sub/name.md -> <pendingDir>/sub/name.md inside pending/, but nested Three committed tests asserted these must be rejected and were right: the design says "a todo name is a basename, not a path", and #4327 requires the resolved path stay inside "the todos root (pending and completed subdirs)". Containment against a root is structurally incapable of expressing "basename" — it answers "is this inside?", and all three are. The wrong tool was reaching for the wrong question. A basename guard now runs BEFORE any path is joined: reject on a `/` or `\` separator, on a path.basename / path.win32.basename mismatch, on `.` / `..`, and on a NUL byte. Both separators are checked explicitly because on POSIX a literal backslash is an ordinary filename character to path.basename but not to path.win32.basename or to the user's intent — this repo has a documented bug class for exactly that asymmetry. Same predicate shape as findPhaseArtifact in check-command-router.cts, so the two agree. Containment is kept as defense-in-depth rather than replaced. The basename guard is the specific rule; containment is the backstop. Message wording matters here and is deliberate: `sub/name.md` does NOT escape its allowed directory, so reusing the escape message would have stated something false. It now says the name must be a plain filename, not a path. docs/CLI-TOOLS.md corrected again, in the opposite direction from last time. The previous revision said an absolute filename is "folded under the root" and 404s — true then, false now: the basename guard rejects it before any join happens. Two corrections to one paragraph in one phase is the cost of documenting behavior while it is still moving; the paragraph now matches the shipped code. Verified through the real CLI, not by calling the built function directly: all eight rejection cases produce the new USAGE message; `ok.md` still completes and moves to completed/; `missing.md` still gives "Todo not found". Also corrected the now-stale comment above the isFile() check — it described `.`/`..` reaching that line, which the basename guard now prevents. The check itself stays: a bare basename can still name a directory, FIFO or socket in pending/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3490,6 +3490,30 @@ function cmdTodoComplete(cwd: string, filename: string | undefined, options: Tod
|
||||
const todosRoot = todosDir(cwd);
|
||||
const pendingDir = path.join(todosRoot, 'pending');
|
||||
const completedDir = path.join(todosRoot, 'completed');
|
||||
|
||||
// #4652: containment against todosRoot only rejects paths that leave the
|
||||
// root — it cannot express "a todo name is a basename, not a path" (see
|
||||
// #4327). `../sibling.md`, `a/../../b.md`, and `sub/name.md` all resolve
|
||||
// to a location inside todosRoot (or inside pending/) and would pass
|
||||
// containment, yet none of them is a bare filename. Reject on basename
|
||||
// shape FIRST, before any path is even joined — same predicate shape as
|
||||
// findPhaseArtifact in check-command-router.cts. Checking both `/` and
|
||||
// `\` explicitly (not just path.basename) matters on POSIX, where a
|
||||
// literal backslash is just an ordinary filename character to
|
||||
// path.basename but not to path.win32.basename or to the user's intent.
|
||||
const rawFilename = filename as string;
|
||||
if (
|
||||
rawFilename === '.' ||
|
||||
rawFilename === '..' ||
|
||||
rawFilename.includes('\0') ||
|
||||
rawFilename.includes('/') ||
|
||||
rawFilename.includes('\\') ||
|
||||
path.basename(rawFilename) !== rawFilename ||
|
||||
path.win32.basename(rawFilename) !== rawFilename
|
||||
) {
|
||||
error(`todo name must be a plain filename inside the pending directory, not a path: ${rawFilename}`, ERROR_REASON.USAGE);
|
||||
}
|
||||
|
||||
const sourcePath = path.join(pendingDir, filename as string);
|
||||
const targetPath = path.join(completedDir, filename as string);
|
||||
|
||||
@@ -3509,10 +3533,13 @@ function cmdTodoComplete(cwd: string, filename: string | undefined, options: Tod
|
||||
error(`Todo not found: ${filename as string}`);
|
||||
}
|
||||
|
||||
// #4652: `.` and `..` resolve to the pending dir itself (which IS inside
|
||||
// todosRoot, so containment passes) but are not a todo file — reject them
|
||||
// the same way as any other invalid name instead of letting
|
||||
// fs.readFileSync throw an uncaught EISDIR with an absolute-path stack trace.
|
||||
// #4652: a name that IS a bare basename can still resolve to something that
|
||||
// is not a regular file — a directory, symlink-to-directory, FIFO or socket
|
||||
// sitting in pending/ under an ordinary-looking name. `.` and `..` no longer
|
||||
// reach here (the basename guard above rejects them first), so this is not
|
||||
// about traversal; it stops fs.readFileSync from throwing an uncaught EISDIR
|
||||
// with an absolute-path stack trace where every sibling case gives a clean
|
||||
// USAGE rejection.
|
||||
if (!fs.statSync(resolvedSource).isFile()) {
|
||||
error(`todo name is not a file: ${filename as string}`, ERROR_REASON.USAGE);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user