Repository navigation
feat(file-safety): canonical advisory lock key (U2, #1375) #1911
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d5f8a79
aa0cdab
c4120b0
97b599d
435be8b
625a976
cf86a42
a587a07
f7de754
4ea379b
8ba6fca
2945ab2
8c8db37
596f2d3
628fcaf
75dd374
6d0bc87
4fa16f2
d5a68d1
5f93883
e20939c
1190af8
2139034
ed9b234
8e0c6c6
6e5706a
7aa579d
0580426
793353a
7532ed2
a06845b
d29b7a3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| import { execFileSync } 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 { | ||
| const out = execFileSync(SHORT_NAME_PROBE_BIN, ["/c", "for %A in (" + directory + ") do @echo %~sA"], { | ||
| encoding: "utf8", | ||
| }).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 { | ||
| if (probeBase !== undefined) { | ||
| fs.rmSync(probeBase, { recursive: true, force: true }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
| }, | ||
| ) | ||
|
|
||
| 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) | ||
| }, | ||
| ) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 2864
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 33550
Web query:
Microsoft Windows cmd for command set spaces quoted filenames %~sI short name documentationResult:
Web query:
Windows cmd FOR unquoted set space separated filenames %~sI nonexistent path expansion exampleResult:
Web query:
Windows cmd FOR quoted path with spaces in set %~sI 8.3 short path exampleResult:
Web query:
Windows cmd "for" "%~s" nonexistent file returns empty short pathResult:
Quote the directory in both short-path probes.
If the Windows temp path contains a space,
forsplits the directory into separate entries. The probes can skip available alias coverage or pass multiline output to the tests as a path. Quote the directory in bothshortPathOfandshortSpellingOf.