javi-forge 1.9.0 → 1.9.1

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.
Files changed (2) hide show
  1. package/dist/commands/ci.js +71 -20
  2. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { constants } from "node:fs";
4
+ import fsp from "node:fs/promises";
4
5
  import path from "node:path";
5
6
  import fs from "fs-extra";
6
7
  import { HOOK_ASSETS_DIR } from "../constants.js";
@@ -284,13 +285,21 @@ async function isGhaggaAvailable() {
284
285
  function report(onStep, id, label, status, detail) {
285
286
  onStep({ id, label, status, detail });
286
287
  }
287
- /** Detect-step label: legacy format for auto, explicit otherwise. */
288
+ /**
289
+ * Detect-step label: legacy format for auto, explicit otherwise.
290
+ *
291
+ * INVARIANT (holds for every `ResolvedRunners` value): `runners` is never
292
+ * empty. The config path rejects an empty list before resolving
293
+ * (`src/lib/ci-config.ts` — "runners is required and must be a non-empty
294
+ * list"), and the `auto` and `stack-override` paths each yield exactly one
295
+ * runner. `runners[0]` therefore needs no fallback.
296
+ */
288
297
  function describeRunners(resolved) {
289
298
  const first = resolved.runners[0];
290
- if (resolved.source === "auto" && first) {
299
+ if (resolved.source === "auto") {
291
300
  return `Stack: ${first.stack} (${first.buildTool})`;
292
301
  }
293
- if (resolved.source === "stack-override" && first) {
302
+ if (resolved.source === "stack-override") {
294
303
  return `Stack: ${first.stack} (${first.buildTool}, --stack override)`;
295
304
  }
296
305
  const summary = resolved.runners
@@ -317,14 +326,16 @@ export async function runCI(options, onStep) {
317
326
  }
318
327
  // Legacy single-runner view for the zero-config auto path. Keeping this
319
328
  // shape guarantees single-stack repositories behave exactly as before.
329
+ // `runners[0]` is always present — see the invariant on `describeRunners`.
330
+ // The command lists CAN be empty, so those keep their `?? null`.
320
331
  const primary = resolved.runners[0];
321
332
  const stackInfo = {
322
- stackType: primary?.stack ?? "node",
323
- buildTool: primary?.buildTool ?? "npm",
324
- javaVersion: primary?.javaVersion ?? "21",
325
- lintCmd: primary?.lintCmds[0] ?? null,
326
- compileCmd: primary?.compileCmds[0] ?? null,
327
- testCmd: primary?.testCmds[0] ?? null,
333
+ stackType: primary.stack,
334
+ buildTool: primary.buildTool,
335
+ javaVersion: primary.javaVersion,
336
+ lintCmd: primary.lintCmds[0] ?? null,
337
+ compileCmd: primary.compileCmds[0] ?? null,
338
+ testCmd: primary.testCmds[0] ?? null,
328
339
  };
329
340
  // ── Detect mode ─────────────────────────────────────────────────────────────
330
341
  if (mode === "detect")
@@ -876,7 +887,16 @@ async function backupHook(hookPath) {
876
887
  }
877
888
  throw new Error(`could not write the backup ${candidate} (${errorCode(copyErr) || "unknown error"}): ${copyErr instanceof Error ? copyErr.message : String(copyErr)}. The hook was left unchanged.`);
878
889
  }
879
- await fs.chmod(candidate, original.mode);
890
+ // The mode restore addresses the FD of the file THIS call just created,
891
+ // not the path: a symlink planted at `candidate` after the
892
+ // `COPYFILE_EXCL` copy cannot capture the mode change (SEC-1).
893
+ const handle = await fsp.open(candidate, constants.O_RDONLY | O_NOFOLLOW);
894
+ try {
895
+ await handle.chmod(original.mode);
896
+ }
897
+ finally {
898
+ await handle.close();
899
+ }
880
900
  return candidate;
881
901
  }
882
902
  throw new Error(`could not back up ${hookPath}: every candidate backup path is taken. The hook was left unchanged.`);
@@ -900,13 +920,51 @@ function assertHookManifestEntry(entry, hookName) {
900
920
  ? `has no "${hookName}" entry`
901
921
  : typeof entry.sha256 !== "string" ||
902
922
  typeof entry.version !== "number" ||
903
- !Array.isArray(entry.historical)
904
- ? `has a malformed "${hookName}" entry (expected version:number, sha256:string, historical:array)`
923
+ !Array.isArray(entry.historical) ||
924
+ !entry.historical.every((h) => typeof h?.sha256 === "string")
925
+ ? `has a malformed "${hookName}" entry (expected version:number, sha256:string, historical:array of {sha256:string})`
905
926
  : "";
906
927
  if (problem !== "") {
907
928
  throw new Error(`${MANIFEST_PATH} ${problem}. The javi-forge install is incomplete; ${REINSTALL_REMEDY}.`);
908
929
  }
909
930
  }
931
+ /**
932
+ * `O_NOFOLLOW` on the platforms that have it, a no-op flag elsewhere. Windows
933
+ * has no such flag AND no hook-symlink threat worth the crash of an
934
+ * `undefined` in a bitmask.
935
+ */
936
+ const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
937
+ /**
938
+ * Write the hook through a FILE DESCRIPTOR, never through the path (SEC-1).
939
+ *
940
+ * `classifyHookPath` established moments earlier that `hookPath` is a regular
941
+ * file, but a local attacker with write access to `.git/hooks` could swap a
942
+ * symlink in during that window and turn the write into an arbitrary-write
943
+ * primitive. `O_NOFOLLOW` closes it: if the path IS a symlink when the write
944
+ * finally happens, `open` fails with `ELOOP` and the hook surfaces as a named
945
+ * per-hook error instead of clobbering the link target. Everything after the
946
+ * open then addresses the FD — `fchmod`, not a second path lookup — so the
947
+ * bytes and the mode provably land on the same inode.
948
+ *
949
+ * The mode argument applies ONLY when the file is CREATED: overwriting an
950
+ * existing 0644 hook would leave it non-executable and git would silently skip
951
+ * it. The `fchmod` is therefore unconditional, on every write path, which also
952
+ * makes the final mode independent of the umask.
953
+ *
954
+ * NOT covered (deferred by decision in SEC-1): a hardlink to a victim file
955
+ * survives `O_NOFOLLOW`. On modern Linux `fs.protected_hardlinks=1` blocks the
956
+ * cross-owner case; an `nlink > 1` refusal is parked in the backlog.
957
+ */
958
+ async function writeHookFile(hookPath, content) {
959
+ const handle = await fsp.open(hookPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | O_NOFOLLOW, HOOK_MODE);
960
+ try {
961
+ await handle.writeFile(content, "utf8");
962
+ await handle.chmod(HOOK_MODE);
963
+ }
964
+ finally {
965
+ await handle.close();
966
+ }
967
+ }
910
968
  /**
911
969
  * Bring an already-current hook back to mode 0755 WITHOUT writing bytes.
912
970
  *
@@ -997,14 +1055,7 @@ export async function installCIHooks(projectDir, options = {}) {
997
1055
  backups.push(await backupHook(hookPath));
998
1056
  }
999
1057
  const body = await readHookBody(name);
1000
- // The `mode` option applies ONLY when the file is CREATED: overwriting
1001
- // an existing 0644 hook would leave it non-executable and git would
1002
- // silently skip it. The chmod is therefore unconditional, on every
1003
- // write path, which also makes the mode independent of the umask.
1004
- await fs.writeFile(hookPath, renderHook(body, name, entry), {
1005
- mode: HOOK_MODE,
1006
- });
1007
- await fs.chmod(hookPath, HOOK_MODE);
1058
+ await writeHookFile(hookPath, renderHook(body, name, entry));
1008
1059
  // `upgraded` means "javi-forge content was replaced by newer
1009
1060
  // javi-forge content". A forced overwrite of someone else's file is a
1010
1061
  // fresh install, not an upgrade.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {