fallow 2.81.0 → 2.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ // Shared launcher used by bin/fallow, bin/fallow-lsp, and bin/fallow-mcp.
2
+ //
3
+ // 1. Resolves the platform package for the current process (platform + arch + libc).
4
+ // 2. Runs ensureVerified (Ed25519 + SHA-256 lazy first-run verify).
5
+ // 3. Execs the platform binary.
6
+ // 4. For `<bin> --version`, appends a `verified: ...` status line to stdout
7
+ // so procurement teams have a single command that surfaces the integrity
8
+ // posture (replaces the install-time confirmation message removed when
9
+ // postinstall verification was retired for RFC 868 readiness).
10
+
11
+ const { execFileSync } = require("node:child_process");
12
+ const path = require("node:path");
13
+ const fs = require("node:fs");
14
+
15
+ const { getPlatformPackage } = require("./platform-package");
16
+ const { ensureVerified } = require("./lazy-verify");
17
+
18
+ function resolvePlatformPackageName() {
19
+ if (process.platform !== "linux") {
20
+ return getPlatformPackage(process.platform, process.arch);
21
+ }
22
+ try {
23
+ const { familySync } = require("detect-libc");
24
+ return getPlatformPackage(process.platform, process.arch, familySync());
25
+ } catch {
26
+ // musl binaries are statically linked and work on both glibc and musl
27
+ return getPlatformPackage(process.platform, process.arch, "musl");
28
+ }
29
+ }
30
+
31
+ function isVersionQuery(argv) {
32
+ // clap registers both --version and -V on the root command.
33
+ const tail = argv.slice(2);
34
+ if (tail.length === 0) return false;
35
+ return tail[0] === "--version" || tail[0] === "-V";
36
+ }
37
+
38
+ function describeVerified(result) {
39
+ if (result.skipped) {
40
+ return `verified: skipped (${result.reason})`;
41
+ }
42
+ if (result.ok) {
43
+ if (result.cached) {
44
+ return `verified: yes (cache hit at ${result.sentinelPath})`;
45
+ }
46
+ if (result.sentinelPath) {
47
+ return `verified: yes (sentinel ${result.sentinelPath})`;
48
+ }
49
+ return "verified: yes (sentinel not persisted)";
50
+ }
51
+ return `verified: no (${result.code})`;
52
+ }
53
+
54
+ // Resolve the platform package directory + manifest path, or print an
55
+ // actionable error and exit. Keeps `runBinary` a flat top-level sequence.
56
+ function resolvePlatformPaths() {
57
+ const pkg = resolvePlatformPackageName();
58
+ if (!pkg) {
59
+ process.stderr.write(`Unsupported platform: ${process.platform}-${process.arch}\n`);
60
+ process.exit(1);
61
+ }
62
+ try {
63
+ const manifestPath = require.resolve(`${pkg}/package.json`);
64
+ return { pkg, manifestPath, platformPkgDir: path.dirname(manifestPath) };
65
+ } catch {
66
+ process.stderr.write(
67
+ `Could not find ${pkg}. Run 'npm install' to install the platform-specific binary.\n`,
68
+ );
69
+ process.exit(1);
70
+ }
71
+ }
72
+
73
+ function printVerifyError(verifyResult) {
74
+ const where = verifyResult.binary ? ` ${verifyResult.binary}` : "";
75
+ process.stderr.write(
76
+ `fallow: binary verification failed${where} (${verifyResult.code}): ${verifyResult.message}\n` +
77
+ `See https://github.com/fallow-rs/fallow/blob/main/SECURITY.md for the trust model. ` +
78
+ `Set FALLOW_SKIP_BINARY_VERIFY=1 only when you deliberately replace the published binary.\n`,
79
+ );
80
+ }
81
+
82
+ function writeVerifiedLineIfVersionQuery(verifyResult) {
83
+ if (isVersionQuery(process.argv)) {
84
+ process.stdout.write(`${describeVerified(verifyResult)}\n`);
85
+ }
86
+ }
87
+
88
+ // Swallow EPIPE on stdout. When fallow's output is piped into a reader that
89
+ // closes early (e.g. `fallow --version | head`), the trailing `verified:`
90
+ // status line would otherwise surface as an unhandled EPIPE 'error' event and
91
+ // dump a Node stack trace. EPIPE arrives as an async 'error' event on the
92
+ // stdout stream, not as a throw, so a try/catch around the write cannot catch
93
+ // it. The child binary's primary output is already written via inherited
94
+ // stdio; the status line is best-effort, so exit cleanly once the reader is
95
+ // gone. Scoped to stdout so a genuine error write to stderr still sets exit 1.
96
+ function guardBrokenStdout() {
97
+ process.stdout.on("error", (err) => {
98
+ if (err && err.code === "EPIPE") {
99
+ process.exit(0);
100
+ }
101
+ throw err;
102
+ });
103
+ }
104
+
105
+ function runBinary(binaryBaseName) {
106
+ guardBrokenStdout();
107
+ const { pkg, manifestPath, platformPkgDir } = resolvePlatformPaths();
108
+
109
+ const binaryName = process.platform === "win32" ? `${binaryBaseName}.exe` : binaryBaseName;
110
+ const binaryPath = path.join(platformPkgDir, binaryName);
111
+ if (!fs.existsSync(binaryPath)) {
112
+ process.stderr.write(`Binary not found at ${binaryPath}\n`);
113
+ process.exit(1);
114
+ }
115
+
116
+ // Lazy first-run verify. Errors are user-facing.
117
+ const verifyResult = ensureVerified({ platformPkgDir, packageName: pkg, manifestPath });
118
+ if (!verifyResult.ok) {
119
+ printVerifyError(verifyResult);
120
+ process.exit(1);
121
+ }
122
+
123
+ try {
124
+ execFileSync(binaryPath, process.argv.slice(2), { stdio: "inherit" });
125
+ } catch (e) {
126
+ if (e.status === undefined) throw e;
127
+ // Child has already written its --version line via inherited stdio;
128
+ // append the verified line here only on a clean exit.
129
+ if (e.status === 0) writeVerifiedLineIfVersionQuery(verifyResult);
130
+ process.exit(e.status);
131
+ }
132
+
133
+ writeVerifiedLineIfVersionQuery(verifyResult);
134
+ }
135
+
136
+ module.exports = {
137
+ runBinary,
138
+ describeVerified, // test-only
139
+ isVersionQuery, // test-only
140
+ guardBrokenStdout, // test-only
141
+ };
@@ -0,0 +1,39 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const { spawnSync } = require("node:child_process");
4
+ const path = require("node:path");
5
+
6
+ const RUN_BINARY = path.join(__dirname, "run-binary.js");
7
+
8
+ // Run a child that installs guardBrokenStdout, then emits a synthetic stdout
9
+ // 'error' with the given code. Node delivers a broken-pipe failure as exactly
10
+ // this event ("Emitted 'error' event on Socket instance"), so emitting it is a
11
+ // faithful reproduction of `fallow --version | head` without needing a live
12
+ // pipe or an installed @fallow-cli platform package. Requiring run-binary.js
13
+ // has no side effects beyond defining functions, so no binary is resolved.
14
+ function runGuardChild(errorCode) {
15
+ const script =
16
+ `const { guardBrokenStdout } = require(${JSON.stringify(RUN_BINARY)});` +
17
+ `guardBrokenStdout();` +
18
+ `process.stdout.emit("error", Object.assign(new Error("write ${errorCode}"), { code: "${errorCode}" }));` +
19
+ // Reached only if the guard neither exited (EPIPE) nor rethrew (other).
20
+ `process.exit(42);`;
21
+ return spawnSync(process.execPath, ["-e", script], { encoding: "utf8" });
22
+ }
23
+
24
+ test("guardBrokenStdout swallows EPIPE on stdout and exits 0", () => {
25
+ const res = runGuardChild("EPIPE");
26
+ assert.equal(res.status, 0, "EPIPE on stdout should exit 0 cleanly, not crash");
27
+ assert.doesNotMatch(res.stderr, /EPIPE/, "no EPIPE stack trace on stderr");
28
+ });
29
+
30
+ test("guardBrokenStdout rethrows non-EPIPE stdout errors (exit 1)", () => {
31
+ const res = runGuardChild("ENOSPC");
32
+ assert.equal(res.status, 1, "a non-EPIPE stdout error must surface, not be swallowed");
33
+ // Match the thrown error's header ("Error: write ENOSPC"), not just the
34
+ // message substring: a missing-guard TypeError would leak the script source
35
+ // (`new Error("write ENOSPC")`) into its code frame and match a looser regex,
36
+ // masking a regression. The colon-space header only appears on a real rethrow.
37
+ assert.match(res.stderr, /Error: write ENOSPC/, "the rethrown error reaches stderr");
38
+ assert.doesNotMatch(res.stderr, /is not a function/, "guard must be present, not absent");
39
+ });
@@ -0,0 +1,165 @@
1
+ // Cascading cache-dir resolution for the lazy-verify sentinel.
2
+ //
3
+ // Preference order:
4
+ // 1. <platform-pkg-dir>/.fallow-verified
5
+ // 2. $FALLOW_VERIFY_CACHE_DIR/<package-id>.json
6
+ // 3. $XDG_CACHE_HOME/fallow/sentinels/<package-id>.json (Linux/macOS)
7
+ // or %LOCALAPPDATA%\fallow\sentinels\<package-id>.json (Windows)
8
+ // or ~/.cache/fallow/sentinels/<package-id>.json (POSIX fallback)
9
+ // 4. Every location read-only: returns { path: null, location: 'none', writable: false }.
10
+ // Callers run verify on every invocation and surface FALLOW_SKIP_BINARY_VERIFY=1 as the escape.
11
+ //
12
+ // Refs RFC 868 (npm/cli#9360). See .plans/rfc-868-lazy-binary-verify.md.
13
+
14
+ const fs = require("node:fs");
15
+ const os = require("node:os");
16
+ const path = require("node:path");
17
+
18
+ const SENTINEL_FILENAME = ".fallow-verified";
19
+
20
+ // Returns true when the directory exists and the current process can create
21
+ // a file in it. Tries an atomic O_CREAT|O_EXCL write so we never disturb an
22
+ // existing sentinel during the writability probe. Falls back to fs.accessSync
23
+ // when mkdtempSync fails for non-permission reasons.
24
+ function isWritable(dir) {
25
+ if (typeof dir !== "string" || dir.length === 0) {
26
+ return false;
27
+ }
28
+ let stat;
29
+ try {
30
+ stat = fs.statSync(dir);
31
+ } catch {
32
+ return false;
33
+ }
34
+ if (!stat.isDirectory()) {
35
+ return false;
36
+ }
37
+ // Probe by creating a unique zero-byte file and deleting it. Cross-platform
38
+ // and tolerant of pnpm/yarn/bun store layouts where the dir is real but the
39
+ // package manager owns its contents.
40
+ const probe = path.join(dir, `.fallow-verify-probe-${process.pid}-${Date.now()}`);
41
+ let fd;
42
+ try {
43
+ fd = fs.openSync(probe, "wx");
44
+ fs.closeSync(fd);
45
+ fs.unlinkSync(probe);
46
+ return true;
47
+ } catch {
48
+ if (fd !== undefined) {
49
+ try {
50
+ fs.closeSync(fd);
51
+ } catch {}
52
+ }
53
+ return false;
54
+ }
55
+ }
56
+
57
+ function ensureDirExists(dir) {
58
+ try {
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ function xdgCacheRoot(env, homeDir, platformId) {
67
+ if (platformId === "win32") {
68
+ return env.LOCALAPPDATA && env.LOCALAPPDATA.length > 0 ? env.LOCALAPPDATA : null;
69
+ }
70
+ if (env.XDG_CACHE_HOME && env.XDG_CACHE_HOME.length > 0) {
71
+ return env.XDG_CACHE_HOME;
72
+ }
73
+ return homeDir ? path.join(homeDir, ".cache") : null;
74
+ }
75
+
76
+ // Convert an npm package name like "@fallow-cli/darwin-arm64" into a stable
77
+ // filesystem-safe identifier used as the sentinel's file basename when the
78
+ // sentinel lives outside the platform package directory.
79
+ function packageIdToFilename(packageName) {
80
+ if (typeof packageName !== "string" || packageName.length === 0) {
81
+ return "unknown.json";
82
+ }
83
+ return `${packageName.replace(/^@/, "").replace(/[/\\]/g, "__")}.json`;
84
+ }
85
+
86
+ // Each cascade step is a small helper that returns a resolved sentinel
87
+ // descriptor or null. Resolving in this shape keeps `resolveSentinelPath`
88
+ // itself a flat for-loop so cyclomatic complexity stays low.
89
+
90
+ function tryPlatformPkgDir(platformPkgDir, writableProbe) {
91
+ if (typeof platformPkgDir !== "string" || platformPkgDir.length === 0) {
92
+ return null;
93
+ }
94
+ if (!writableProbe(platformPkgDir)) {
95
+ return null;
96
+ }
97
+ return {
98
+ path: path.join(platformPkgDir, SENTINEL_FILENAME),
99
+ location: "platform-pkg",
100
+ writable: true,
101
+ };
102
+ }
103
+
104
+ function tryCacheDirEnv(env, filename, ensureDir, writableProbe) {
105
+ const dir = env.FALLOW_VERIFY_CACHE_DIR;
106
+ if (!dir || dir.length === 0) return null;
107
+ if (!ensureDir(dir) || !writableProbe(dir)) return null;
108
+ return { path: path.join(dir, filename), location: "cache-dir-env", writable: true };
109
+ }
110
+
111
+ function xdgLocationLabel(env, platformId) {
112
+ if (platformId === "win32") return "localappdata";
113
+ return env.XDG_CACHE_HOME ? "xdg" : "home-cache";
114
+ }
115
+
116
+ function tryXdgFallback(env, homeDir, platformId, filename, ensureDir, writableProbe) {
117
+ const root = xdgCacheRoot(env, homeDir, platformId);
118
+ if (!root) return null;
119
+ const dir = path.join(root, "fallow", "sentinels");
120
+ if (!ensureDir(dir) || !writableProbe(dir)) return null;
121
+ return {
122
+ path: path.join(dir, filename),
123
+ location: xdgLocationLabel(env, platformId),
124
+ writable: true,
125
+ };
126
+ }
127
+
128
+ // Resolve the sentinel path according to the cascade documented above.
129
+ // Dependency-inject env / homedir / platform / fsProbe so the unit tests can
130
+ // exercise every branch without touching the real filesystem state.
131
+ //
132
+ // Returns: {
133
+ // path: string | null, // null means "no writable cache location"
134
+ // location: 'platform-pkg' | 'cache-dir-env' | 'xdg' | 'localappdata' | 'home-cache' | 'none',
135
+ // writable: boolean,
136
+ // }
137
+ function resolveSentinelPath(options) {
138
+ const opts = options || {};
139
+ const env = opts.env || process.env;
140
+ // Using `in` so an explicit `homedir: undefined` opts out of the os.homedir()
141
+ // fallback (tests rely on this to exercise the "no cache home" branch).
142
+ const homeDir = "homedir" in opts ? opts.homedir : os.homedir();
143
+ const platformId = opts.platform || process.platform;
144
+ const writableProbe = typeof opts.isWritable === "function" ? opts.isWritable : isWritable;
145
+ const ensureDir = typeof opts.ensureDir === "function" ? opts.ensureDir : ensureDirExists;
146
+ const filename = packageIdToFilename(opts.packageName);
147
+
148
+ return (
149
+ tryPlatformPkgDir(opts.platformPkgDir, writableProbe) ||
150
+ tryCacheDirEnv(env, filename, ensureDir, writableProbe) ||
151
+ tryXdgFallback(env, homeDir, platformId, filename, ensureDir, writableProbe) || {
152
+ path: null,
153
+ location: "none",
154
+ writable: false,
155
+ }
156
+ );
157
+ }
158
+
159
+ module.exports = {
160
+ SENTINEL_FILENAME,
161
+ packageIdToFilename,
162
+ resolveSentinelPath,
163
+ // exported for tests
164
+ _isWritable: isWritable,
165
+ };
@@ -0,0 +1,236 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+
7
+ const {
8
+ SENTINEL_FILENAME,
9
+ packageIdToFilename,
10
+ resolveSentinelPath,
11
+ _isWritable,
12
+ } = require("./sentinel-path");
13
+
14
+ function mkTmp() {
15
+ return fs.mkdtempSync(path.join(os.tmpdir(), "fallow-sentinel-path-"));
16
+ }
17
+
18
+ function cleanup(dir) {
19
+ fs.rmSync(dir, { recursive: true, force: true });
20
+ }
21
+
22
+ test("packageIdToFilename normalises scoped package names", () => {
23
+ assert.equal(packageIdToFilename("@fallow-cli/darwin-arm64"), "fallow-cli__darwin-arm64.json");
24
+ assert.equal(packageIdToFilename("@fallow-cli/linux-x64-gnu"), "fallow-cli__linux-x64-gnu.json");
25
+ assert.equal(packageIdToFilename("fallow"), "fallow.json");
26
+ assert.equal(packageIdToFilename(""), "unknown.json");
27
+ assert.equal(packageIdToFilename(undefined), "unknown.json");
28
+ });
29
+
30
+ test("packageIdToFilename strips backslashes too (Windows scoped paths)", () => {
31
+ assert.equal(packageIdToFilename("@scope\\name"), "scope__name.json");
32
+ });
33
+
34
+ test("isWritable returns true for a writable tmpdir", () => {
35
+ const dir = mkTmp();
36
+ try {
37
+ assert.equal(_isWritable(dir), true);
38
+ } finally {
39
+ cleanup(dir);
40
+ }
41
+ });
42
+
43
+ test("isWritable returns false for a non-existent path", () => {
44
+ assert.equal(_isWritable("/this/path/does/not/exist/probably"), false);
45
+ assert.equal(_isWritable(""), false);
46
+ assert.equal(_isWritable(undefined), false);
47
+ });
48
+
49
+ test("isWritable returns false when the target is a file, not a dir", () => {
50
+ const dir = mkTmp();
51
+ try {
52
+ const filePath = path.join(dir, "a-file");
53
+ fs.writeFileSync(filePath, "x");
54
+ assert.equal(_isWritable(filePath), false);
55
+ } finally {
56
+ cleanup(dir);
57
+ }
58
+ });
59
+
60
+ test("resolveSentinelPath prefers the platform pkg dir when writable", () => {
61
+ const platformPkgDir = mkTmp();
62
+ try {
63
+ const result = resolveSentinelPath({
64
+ platformPkgDir,
65
+ packageName: "@fallow-cli/darwin-arm64",
66
+ env: {},
67
+ });
68
+ assert.equal(result.location, "platform-pkg");
69
+ assert.equal(result.writable, true);
70
+ assert.equal(result.path, path.join(platformPkgDir, SENTINEL_FILENAME));
71
+ } finally {
72
+ cleanup(platformPkgDir);
73
+ }
74
+ });
75
+
76
+ test("resolveSentinelPath falls back to FALLOW_VERIFY_CACHE_DIR when platform pkg dir is non-writable", () => {
77
+ const cacheDir = mkTmp();
78
+ try {
79
+ const result = resolveSentinelPath({
80
+ platformPkgDir: "/dev/null/not-a-dir",
81
+ packageName: "@fallow-cli/darwin-arm64",
82
+ env: { FALLOW_VERIFY_CACHE_DIR: cacheDir },
83
+ });
84
+ assert.equal(result.location, "cache-dir-env");
85
+ assert.equal(result.writable, true);
86
+ assert.equal(result.path, path.join(cacheDir, "fallow-cli__darwin-arm64.json"));
87
+ } finally {
88
+ cleanup(cacheDir);
89
+ }
90
+ });
91
+
92
+ test("resolveSentinelPath honors FALLOW_VERIFY_CACHE_DIR even when platform pkg dir IS writable", () => {
93
+ // Per the cascade documented in the source, the platform pkg dir wins when
94
+ // writable. The cache-dir env is the FALLBACK for when the platform dir is
95
+ // read-only. We pass a non-existent platform dir to force the fallback.
96
+ const cacheDir = mkTmp();
97
+ try {
98
+ const result = resolveSentinelPath({
99
+ platformPkgDir: undefined,
100
+ packageName: "@fallow-cli/linux-x64-gnu",
101
+ env: { FALLOW_VERIFY_CACHE_DIR: cacheDir },
102
+ });
103
+ assert.equal(result.location, "cache-dir-env");
104
+ assert.equal(result.path, path.join(cacheDir, "fallow-cli__linux-x64-gnu.json"));
105
+ } finally {
106
+ cleanup(cacheDir);
107
+ }
108
+ });
109
+
110
+ test("resolveSentinelPath falls back to XDG_CACHE_HOME on Linux/macOS", () => {
111
+ const homeDir = mkTmp();
112
+ const xdg = path.join(homeDir, "xdg-cache");
113
+ fs.mkdirSync(xdg);
114
+ try {
115
+ const result = resolveSentinelPath({
116
+ platformPkgDir: undefined,
117
+ packageName: "@fallow-cli/darwin-arm64",
118
+ env: { XDG_CACHE_HOME: xdg },
119
+ homedir: homeDir,
120
+ platform: "darwin",
121
+ });
122
+ assert.equal(result.location, "xdg");
123
+ assert.equal(result.writable, true);
124
+ assert.equal(
125
+ result.path,
126
+ path.join(xdg, "fallow", "sentinels", "fallow-cli__darwin-arm64.json"),
127
+ );
128
+ // The fallow/sentinels/ subdir must have been created.
129
+ assert.equal(fs.existsSync(path.join(xdg, "fallow", "sentinels")), true);
130
+ } finally {
131
+ cleanup(homeDir);
132
+ }
133
+ });
134
+
135
+ test("resolveSentinelPath falls back to ~/.cache on Linux/macOS when XDG_CACHE_HOME is unset", () => {
136
+ const homeDir = mkTmp();
137
+ try {
138
+ const result = resolveSentinelPath({
139
+ platformPkgDir: undefined,
140
+ packageName: "@fallow-cli/linux-x64-gnu",
141
+ env: {},
142
+ homedir: homeDir,
143
+ platform: "linux",
144
+ });
145
+ assert.equal(result.location, "home-cache");
146
+ assert.equal(
147
+ result.path,
148
+ path.join(homeDir, ".cache", "fallow", "sentinels", "fallow-cli__linux-x64-gnu.json"),
149
+ );
150
+ } finally {
151
+ cleanup(homeDir);
152
+ }
153
+ });
154
+
155
+ test("resolveSentinelPath uses LOCALAPPDATA on Windows", () => {
156
+ const localAppData = mkTmp();
157
+ try {
158
+ const result = resolveSentinelPath({
159
+ platformPkgDir: undefined,
160
+ packageName: "@fallow-cli/win32-x64-msvc",
161
+ env: { LOCALAPPDATA: localAppData },
162
+ homedir: "/c/Users/test",
163
+ platform: "win32",
164
+ });
165
+ assert.equal(result.location, "localappdata");
166
+ assert.equal(result.writable, true);
167
+ assert.equal(
168
+ result.path,
169
+ path.join(localAppData, "fallow", "sentinels", "fallow-cli__win32-x64-msvc.json"),
170
+ );
171
+ } finally {
172
+ cleanup(localAppData);
173
+ }
174
+ });
175
+
176
+ test("resolveSentinelPath returns no path when every location is non-writable", () => {
177
+ const result = resolveSentinelPath({
178
+ platformPkgDir: undefined,
179
+ packageName: "@fallow-cli/darwin-arm64",
180
+ env: {},
181
+ homedir: undefined,
182
+ platform: "darwin",
183
+ });
184
+ assert.equal(result.path, null);
185
+ assert.equal(result.location, "none");
186
+ assert.equal(result.writable, false);
187
+ });
188
+
189
+ test("resolveSentinelPath returns no path on Windows without LOCALAPPDATA", () => {
190
+ const result = resolveSentinelPath({
191
+ platformPkgDir: undefined,
192
+ packageName: "@fallow-cli/win32-x64-msvc",
193
+ env: {},
194
+ homedir: undefined,
195
+ platform: "win32",
196
+ });
197
+ assert.equal(result.path, null);
198
+ assert.equal(result.location, "none");
199
+ });
200
+
201
+ test("resolveSentinelPath honors injected isWritable + ensureDir for full test isolation", () => {
202
+ const calls = [];
203
+ const result = resolveSentinelPath({
204
+ platformPkgDir: "/synthetic/pkg-dir",
205
+ packageName: "@fallow-cli/darwin-arm64",
206
+ env: {},
207
+ isWritable: (dir) => {
208
+ calls.push(["isWritable", dir]);
209
+ return dir === "/synthetic/pkg-dir";
210
+ },
211
+ ensureDir: (dir) => {
212
+ calls.push(["ensureDir", dir]);
213
+ return true;
214
+ },
215
+ });
216
+ assert.equal(result.location, "platform-pkg");
217
+ assert.deepEqual(calls, [["isWritable", "/synthetic/pkg-dir"]]);
218
+ });
219
+
220
+ test("resolveSentinelPath skips cache-dir-env when ensureDir fails for it", () => {
221
+ // FALLOW_VERIFY_CACHE_DIR points at a non-creatable path, XDG points at a
222
+ // creatable one. Confirm the resolver moves past the env override.
223
+ const homeDir = mkTmp();
224
+ try {
225
+ const result = resolveSentinelPath({
226
+ platformPkgDir: undefined,
227
+ packageName: "@fallow-cli/darwin-arm64",
228
+ env: { FALLOW_VERIFY_CACHE_DIR: "/dev/null/inside/a/file" },
229
+ homedir: homeDir,
230
+ platform: "darwin",
231
+ });
232
+ assert.equal(result.location, "home-cache");
233
+ } finally {
234
+ cleanup(homeDir);
235
+ }
236
+ });