Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
d5f8a79
split unit U1 of PR 1833 (issue 1375)
Oct 5, 2026
aa0cdab
fix(file-safety): close the pre-merge findings on the publish primiti…
Oct 5, 2026
c4120b0
fix(file-safety): propagate a non-ENOENT lstat failure in resolvePubl…
Oct 5, 2026
97b599d
fix(file-safety): keep the rollback pair typed and the mock stand-ins…
Oct 5, 2026
435be8b
rebuild unit u2 on the fixed chain
Oct 5, 2026
625a976
chore(lint): prune the safeWriteJson suppression this unit earns
Oct 5, 2026
cf86a42
fix(file-safety): inherit unit 1 committed guard and exact rmdir asse…
Oct 5, 2026
a587a07
test: rerun windows lane - local core suite is green (1073 tests), th…
Oct 5, 2026
f7de754
fix(file-safety): keep the publish error message in RollbackFailureError
Oct 5, 2026
4ea379b
test(file-safety): assert the exact staging directory removed after a…
Oct 5, 2026
8ba6fca
test: re-trigger required checks - the queued runs were cancelled by …
Oct 5, 2026
2945ab2
fix(file-safety): give the Windows DACL dump a per-write name
Oct 5, 2026
8c8db37
fix(file-safety): keep the published target across a post-commit dura…
easonLiangWorldedtech Oct 16, 2026
596f2d3
chore(merge): refresh onto main 4f1dee18e
easonLiangWorldedtech Oct 16, 2026
628fcaf
style(file-safety): drop imports unused after the main refresh (lint …
easonLiangWorldedtech Oct 16, 2026
75dd374
style(file-safety): reformat specs for the prettier version main now …
easonLiangWorldedtech Oct 16, 2026
6d0bc87
test(file-safety): pin the durability wrapper's cause and the exact s…
easonLiangWorldedtech Oct 16, 2026
4fa16f2
test(file-safety): keep the durability probe platform-portable
easonLiangWorldedtech Oct 16, 2026
d5a68d1
test(file-safety): inject the durability failure without fabricating …
easonLiangWorldedtech Oct 16, 2026
5f93883
fix(file-safety): fall back to the link walk only on a dangling link
easonLiangWorldedtech Oct 16, 2026
e20939c
test(safety): drop the dead lstat mock in the EACCES lock-key test
easonLiangWorldedtech Oct 16, 2026
1190af8
fix(safety): port copy-based backup and harden caller-staged tempPath
easonLiangWorldedtech Oct 16, 2026
2139034
test(safety): pin the identity guard with a convention-conforming alias
easonLiangWorldedtech Oct 16, 2026
ed9b234
test(safety): assert cleanup after the hard-link rejection
easonLiangWorldedtech Oct 17, 2026
8e0c6c6
Merge upstream/main into fws/u2-lock-key
Nov 1, 2026
6e5706a
fix(merge): restore the upstream hunks dropped by the main merge
easonLiangWorldedtech Nov 1, 2026
7aa579d
test(safe-write-json): type the unlink mock and restore it in finally
easonLiangWorldedtech Nov 2, 2026
0580426
fix(file-safety): re-resolve the commit target before the publish rename
easonLiangWorldedtech Nov 2, 2026
793353a
Merge upstream/main 73931fe8be into fws/u2-lock-key
easonLiangWorldedtech Nov 8, 2026
7532ed2
fix(safe-write): compare directory identity in the safeWriteText stag…
easonLiangWorldedtech Nov 8, 2026
a06845b
file-safety: make the 8.3 alias probe answer instead of throwing
easonLiangWorldedtech Nov 8, 2026
d29b7a3
test: intercept the commit rename by identity, not by spelling
easonLiangWorldedtech Nov 8, 2026
9f150f5
fix(file-safety): propagate unreadable links, keep landed commits, sc…
easonLiangWorldedtech Nov 9, 2026
d949cab
fix(jsonFileRecovery): scope leftover tails per prefix so a timestamp…
easonLiangWorldedtech Nov 9, 2026
4b85be9
fix(file-safety): anchor the post-commit injection on the commit rena…
easonLiangWorldedtech Nov 9, 2026
7169173
fix(file-safety): stop the lock-key walk at the file it lands on, and…
easonLiangWorldedtech Nov 9, 2026
f2847c4
tests: cover the empty-token restore path and the EPERM backup token,…
easonLiangWorldedtech Nov 9, 2026
780966d
file-safety: bind the commit rename to the identity the caller author…
easonLiangWorldedtech Nov 9, 2026
5559f1f
file-safety: ask the identity guard about a different live file, not …
easonLiangWorldedtech Nov 9, 2026
2e79e37
file-safety: authorize by bigint file identity, not by a rounded number
easonLiangWorldedtech Nov 9, 2026
ad18ac4
file-safety: give the identity guard a row where the target changes m…
easonLiangWorldedtech Nov 9, 2026
1856b30
test(file-safety): hand the seam's own swap failure to the row
easonLiangWorldedtech Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 234 additions & 0 deletions src/services/file-safety/__tests__/safeWriteText.alias.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { spawnSync } from "child_process"
import * as fs from "fs"
import * as os from "os"
import * as path from "path"

import { safeWriteText, StagingPathError } from "../safeWriteText"

// NTFS hands an 8.3 alias to every component whose name is longer than eight characters, so one
// directory answers to two spellings. The hosted Windows runner is the case that matters: its
// temp directory arrives in the short form under the profile while the writer's resolved target
// arrives in the long spelling, and the staging file created beside that target carries whichever
// spelling the caller's canonicalisation produced. A textual comparison of the two directories then
// rejects a staging file that really is beside the target - that is the StagingPathError the
// windows job reports at the staging guard.
//
// The alias cannot be invented where 8.3 names are not generated, so this file asks cmd for the
// short spelling of a directory it just created and skips the suite where no alias appears: on any
// platform that is not win32, where cmd.exe does not exist and no alias could exist either, and on
// a win32 volume that has 8.3 name generation switched off. The probe has to answer that question
// without throwing: until now the probe ran while the module was being imported, so a worker with no
// cmd.exe failed the import and vitest recorded a failed suite instead of skipping the suite.
// What node 22.23.4 - the version .tool-versions and engines pin -
// does with those two spellings is asserted below rather than assumed: path.resolve preserves
// whichever spelling it is handed, fs.realpath preserves it too, and fs.stat reports one identity
// for both. The guard compares identities for exactly that reason.

const IS_WINDOWS = process.platform === "win32"
const SHORT_NAME_MARKER = "~1"
const SHORT_NAME_PROBE_BIN = "cmd.exe"

// Total: every failure mode answers null so that the caller skips instead of dying. A non-win32
// worker has neither cmd.exe nor 8.3 aliases and is answered without spawning anything; a cmd that
// is missing, refuses the query, or answers without an alias means no alias to test against.
function shortPathOf(directory: string): string | null {
if (!IS_WINDOWS) {
return null
}
try {
// The directory has to reach cmd quoted. cmd's `for ... in (set)` splits an unquoted set
// on spaces, so a directory named "...has a space inside it" becomes six items and cmd
// echoes one line per item, each resolved against cmd's own working directory (measured
// with node 22.23.4: the unquoted form answers six lines for one directory). Quoting the
// item is not enough by itself: quoting argv entries that contain spaces leaves cmd a value
// whose quotes are part of the name, so %~sA hands back the long spelling wrapped in quotes
// and the probe reports no alias at all (measured: three rows skipped, none run).
// windowsVerbatimArguments hands cmd exactly the string written here, which is the only
// measured form that answers a single short path for a directory with a space. It is a
// spawnSync option in the node typings - execFileSync's typed options do not carry it
// (tsc reports TS2769 there), so the probe runs through the API that documents the flag.
// Both measure the same answer in all four quoting combinations (measured with node 22.23.4
// on win32), so the switch changes the typing, not the behaviour.
const probe = spawnSync(SHORT_NAME_PROBE_BIN, ["/c", 'for %A in ("' + directory + '") do @echo %~sA'], {
encoding: "utf8",
windowsVerbatimArguments: true,
})
// The typings say `error: Error | null`; spawnSync actually leaves it undefined on a
// successful run, and a `!== null` test would reject every healthy probe - which is how
// the whole suite ends up silently skipped rather than run.
if (probe.error || probe.status !== 0 || probe.stdout === null) {
return null
}
const out = probe.stdout.trim()
return out.includes(SHORT_NAME_MARKER) ? out : null
} catch {
return null
}
}

// Answered once at import so that it.runIf can decide before any test body runs. Never allowed to
// throw: a throwing probe is a failed import, which vitest reports as a failed suite that no skip
// condition can rescue.
function probeGeneratesEightThreeNames(): boolean {
if (!IS_WINDOWS) {
return false
}
let probeBase: string | undefined
try {
probeBase = fs.mkdtempSync(path.join(os.tmpdir(), "safe-write-alias-probe-"))
const probeDir = path.join(probeBase, "EightThreeNameProbe")
fs.mkdirSync(probeDir)
return shortPathOf(probeDir) !== null
} catch {
return false
} finally {
// Cleanup must not decide the answer. `force` swallows a missing directory but not the
// EPERM/EBUSY Windows reports while a handle is still being released, and a throw from this
// finally block escapes the function at module import time - the failed-suite-with-no-skip-
// that-vitest-can-rescue case this probe exists to avoid.
if (probeBase !== undefined) {
for (let attempt = 0; attempt < 2; attempt++) {
try {
fs.rmSync(probeBase, { recursive: true, force: true })
break
} catch {
if (attempt === 1) break
}
}
}
}
}

const EIGHT_THREE_NAMES_ARE_GENERATED = probeGeneratesEightThreeNames()

describe("safeWriteText staging guard against a second spelling of the target directory", () => {
let tempDir: string

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "safeWriteText-alias-"))
})

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})

function createTargetDirectory(): { longPath: string; shortPath: string | null } {
const longPath = path.join(tempDir, "LONGTARGETDIRECTORYNAME")
fs.mkdirSync(longPath)
return { longPath, shortPath: shortPathOf(longPath) }
}

it.runIf(EIGHT_THREE_NAMES_ARE_GENERATED)(
"publishes a staging file spelled through the target directory own 8.3 alias",
async () => {
const { longPath, shortPath } = createTargetDirectory()
if (shortPath === null) {
return
}
const targetPath = path.join(longPath, "target.txt")
fs.writeFileSync(targetPath, "committed", "utf8")
// The staging file sits beside the target but is spelled through the directory's 8.3
// alias, which is what a caller's own canonicalisation hands the guard.
const stagingPath = path.join(shortPath, ".new_target.txt_1_x.tmp")
fs.writeFileSync(stagingPath, "staged", "utf8")

// Preconditions, asserted rather than assumed: the two directory spellings differ as text
// and this runtime's fs.realpath does not unify them...
expect(path.dirname(stagingPath)).not.toBe(path.dirname(targetPath))
const realViaAlias = fs.realpathSync(path.dirname(stagingPath))
const realViaTarget = fs.realpathSync(path.dirname(targetPath))
expect(realViaAlias).not.toBe(realViaTarget)
// ...while fs.stat reports the same directory for both spellings. That identity is what
// the guard compares, and it is why a rename from here onto the target cannot be EXDEV.
const statViaAlias = fs.statSync(path.dirname(stagingPath), { bigint: true })
const statViaTarget = fs.statSync(path.dirname(targetPath), { bigint: true })
expect(statViaAlias.dev).toBe(statViaTarget.dev)
expect(statViaAlias.ino).toBe(statViaTarget.ino)

await safeWriteText(targetPath, "", { tempPath: stagingPath, backup: false })

expect(fs.readFileSync(targetPath, "utf8")).toBe("staged")
expect(fs.existsSync(stagingPath)).toBe(false)
},
)

// The probe above is the harness for every row in this file, so a command that quotes the directory
// badly answers null for the probe directory and it.runIf skips the whole file: the suite cannot tell
// "this volume generates no 8.3 names" from "our cmd command broke". This row asks its own question,
// about its own directory, and skips only on the platform that has no cmd.exe to ask.
it.runIf(IS_WINDOWS)("checks alias discovery without going through the capability flag", () => {
const spacedDir = path.join(tempDir, "SELF CHECKING TARGET DIRECTORY")
fs.mkdirSync(spacedDir)
// The same command shortPathOf runs, with the answer kept instead of collapsed to null.
const query = 'for %A in ("' + spacedDir + '") do @echo %~sA'
const raw = spawnSync(SHORT_NAME_PROBE_BIN, ["/c", query], {
encoding: "utf8",
windowsVerbatimArguments: true,
})
const answers = raw.stdout.trim().split(/\r?\n/)
// One answer, not one answer per word of the directory name.
expect(answers).toHaveLength(1)
expect(fs.existsSync(answers[0])).toBe(true)
expect(fs.statSync(answers[0]).isDirectory()).toBe(true)
// Where this volume really does hand out an alias - the answer carries the short-name marker - the
// production probe has to answer it for the same directory. A command that lost its quotes answers
// one line per word here and fails this row, where the flag above would only have skipped it.
if (answers[0].includes(SHORT_NAME_MARKER)) {
const short = shortPathOf(spacedDir)
expect(short).not.toBeNull()
expect(short).toBe(answers[0])
}
})

// The probe is the harness for both rows below, and its own answer is what breaks when the
// directory is quoted badly: a space in a directory name is not exotic on Windows - every
// profile path under "Documents and Settings" has one - and an unquoted for-set turns one
// directory into one line per word of its name.
it.runIf(IS_WINDOWS && EIGHT_THREE_NAMES_ARE_GENERATED)(
"answers one short path for a directory whose name contains a space",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
() => {
const spacedDir = path.join(tempDir, "SPACE CONTAINING TARGET DIRECTORY")
fs.mkdirSync(spacedDir)
const short = shortPathOf(spacedDir)
expect(short).not.toBeNull()
if (short === null) {
return
}
// One answer, not one answer per word of the name.
expect(short.split(/\r?\n/)).toHaveLength(1)
// And the answer is a directory that exists, not a token of the name resolved
// against the probe's own working directory.
expect(fs.existsSync(short)).toBe(true)
expect(fs.statSync(short).isDirectory()).toBe(true)
const viaShort = fs.statSync(short, { bigint: true })
const viaLong = fs.statSync(spacedDir, { bigint: true })
expect([String(viaShort.dev), String(viaShort.ino)]).toEqual([String(viaLong.dev), String(viaLong.ino)])
},
)

it.runIf(EIGHT_THREE_NAMES_ARE_GENERATED)(
"still rejects a staging file spelled through another directory 8.3 alias",
async () => {
const { longPath } = createTargetDirectory()
const otherPath = path.join(tempDir, "OTHERTARGETDIRECTORYNAME")
fs.mkdirSync(otherPath)
const otherShort = shortPathOf(otherPath)
if (otherShort === null) {
return
}
const targetPath = path.join(longPath, "target.txt")
fs.writeFileSync(targetPath, "committed", "utf8")
const stagingPath = path.join(otherShort, ".new_target.txt_1_x.tmp")
fs.writeFileSync(stagingPath, "staged", "utf8")

// Same alias mechanism, different directory. Accepting the target directory's alias must
// not become accepting any directory, or a caller could publish an unrelated file onto the
// target through a name the guard was written to refuse.
await expect(safeWriteText(targetPath, "", { tempPath: stagingPath, backup: false })).rejects.toThrow(
StagingPathError,
)
expect(fs.readFileSync(targetPath, "utf8")).toBe("committed")
expect(fs.existsSync(stagingPath)).toBe(true)
},
)
})
Loading
Loading