@norskvideo/ctl-test-harness 0.1.35 → 0.1.37

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.
package/demo/dev-loop.js CHANGED
@@ -199,7 +199,18 @@ export async function devLoopDown(product, deps = defaultDemoDeps(process.cwd())
199
199
  }
200
200
  if (state.devPid !== undefined)
201
201
  deps.killPid?.(state.devPid);
202
- deps.nukeStoreAsRoot(state.storeDir);
202
+ // Never let the nuke strand the state record: it sits one line before
203
+ // state.remove, and the containment guard throws for a store outside THIS
204
+ // process's temp base -- which is a cross-process fact (the `up` shell's
205
+ // NORSK_CTL_TEST_TMP need not be the `down` shell's), not a bug in the run
206
+ // being torn down. Refusing to delete it is right; failing to forget it
207
+ // means `down` breaks identically on every retry.
208
+ try {
209
+ deps.nukeStoreAsRoot(state.storeDir);
210
+ }
211
+ catch (e) {
212
+ deps.log(`nuke skipped: ${e instanceof Error ? e.message : String(e)}`);
213
+ }
203
214
  deps.state.remove(product, "dev-loop");
204
215
  deps.log(`${product} dev-loop torn down (the workdir ${state.workdir ?? ""} is untouched)`);
205
216
  }
package/demo/run.js CHANGED
@@ -36,7 +36,7 @@ import { ctlSupportsNoPublish, runnerContainerUser } from "../launch.js";
36
36
  import { pollUntil } from "../poll.js";
37
37
  import { nukePathAsRoot, reportForeignOwners } from "../root-nuke.js";
38
38
  import { startSrtSources } from "../source-pump.js";
39
- import { makeStoreDir } from "../temp-dir.js";
39
+ import { makeStoreDir, TEST_TMP_BASE } from "../temp-dir.js";
40
40
  const DEMO_PORT_BASE = 35000;
41
41
  const DEMO_BAND_WIDTH = 20;
42
42
  const DEMO_BANDS = 50;
@@ -290,7 +290,7 @@ export function defaultDemoDeps(cwd) {
290
290
  await h.stop();
291
291
  },
292
292
  cleanup: (opts) => cleanupDaemon({ ...opts, stopProxy: async () => { }, proxy: false }),
293
- nukeStoreAsRoot: (storeDir) => nukePathAsRoot(storeDir),
293
+ nukeStoreAsRoot: (storeDir) => nukePathAsRoot(storeDir, TEST_TMP_BASE),
294
294
  storeExists: (storeDir) => existsSync(storeDir),
295
295
  ensureNetwork: () => ensureRunnerOnNetwork(),
296
296
  supportsNoPublish: ctlSupportsNoPublish,
@@ -733,8 +733,19 @@ export async function demoDown(product, deps = defaultDemoDeps(process.cwd())) {
733
733
  deps.docker(["rm", "-f", name]);
734
734
  if (state.devPid !== undefined)
735
735
  deps.killPid?.(state.devPid);
736
- if (state.daemon !== "reuse")
737
- deps.nukeStoreAsRoot(state.storeDir);
736
+ // Never let the nuke strand the state record: it sits one line before
737
+ // state.remove, and the containment guard throws for a store outside THIS
738
+ // process's temp base -- which is a cross-process fact (the `up` shell's
739
+ // NORSK_CTL_TEST_TMP need not be the `down` shell's), not a bug in the run
740
+ // being torn down. Refusing to delete it is right; failing to forget it
741
+ // means `down` breaks identically on every retry.
742
+ try {
743
+ if (state.daemon !== "reuse")
744
+ deps.nukeStoreAsRoot(state.storeDir);
745
+ }
746
+ catch (e) {
747
+ deps.log(`nuke skipped: ${e instanceof Error ? e.message : String(e)}`);
748
+ }
738
749
  deps.state.remove(product);
739
750
  deps.log(`${product} demo torn down${state.daemon === "reuse" ? " (your daemon and its registration are untouched)" : ""}`);
740
751
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/root-nuke.d.ts CHANGED
@@ -3,12 +3,20 @@ export type NukeSpawn = (cmd: string, argv: string[], opts?: unknown) => {
3
3
  error?: unknown;
4
4
  status?: number | null;
5
5
  };
6
- /** Refuse a target that would turn the nuke into something catastrophic. The
7
- * mount is the target's PARENT, so a target with no leaf ("/" or a trailing
8
- * slash) means "delete the mount", and a relative path resolves against a cwd
9
- * the container does not share. These are programming errors, not runtime
10
- * conditions: throw loudly rather than run a root rm against a guess. */
11
- export declare function assertNukeTarget(target: string): void;
6
+ /** Refuse a target that would turn the nuke into something catastrophic.
7
+ *
8
+ * `allowedBase` is the containment: the target must sit strictly INSIDE it.
9
+ * Nothing else here is sufficient on its own, because the worst case passes
10
+ * every structural check. Under `--daemon reuse` the harness's storeDir is the
11
+ * developer's real `~/.norsk-ctl` -- absolute, ordinary leaf, no glob,
12
+ * non-root parent -- and nuking it mounts $HOME into a root container and
13
+ * removes the daemon's entire state: config, products, templates, instances.
14
+ * Today only an early return in teardown keeps the nuke away from it, and a
15
+ * guard that depends on a `return` staying put is not a guard.
16
+ *
17
+ * The base itself is refused too: it is shared by concurrently running tiers,
18
+ * so removing it would delete sibling runs' live stores. */
19
+ export declare function assertNukeTarget(target: string, allowedBase: string): void;
12
20
  /** Docker argv for the nuke. Two things matter here:
13
21
  *
14
22
  * 1. The PARENT is mounted and the leaf removed by name -- mounting the target
@@ -19,11 +27,16 @@ export declare function assertNukeTarget(target: string): void;
19
27
  * directory called `x; rm -rf /base` would otherwise wipe every sibling --
20
28
  * including a live sibling run -- from inside a root container. */
21
29
  export declare function nukeArgv(target: string): string[];
22
- /** Remove `target` as root. Returns whether the nuke actually ran: `spawnSync`
23
- * reports a missing docker in its RESULT rather than throwing, and a caller
24
- * that retries a doomed spawn forever (silently) is the failure mode to avoid.
30
+ /** Remove `target` as root. Returns whether the nuke actually SUCCEEDED.
31
+ *
32
+ * Both halves matter. `spawnSync` reports a missing docker in its RESULT
33
+ * rather than throwing, and a docker that is present but failing -- daemon
34
+ * stopped, image unpullable, mount refused -- reports only a non-zero status
35
+ * with no `error` at all. Reading `error` alone calls that a success, so a
36
+ * caller latching on the return value never latches and re-spawns a doomed
37
+ * 60-second docker on every attempt, forever and silently.
25
38
  * Throws only for an unsafe target -- see {@link assertNukeTarget}. */
26
- export declare function nukePathAsRoot(target: string, spawn?: NukeSpawn): boolean;
39
+ export declare function nukePathAsRoot(target: string, allowedBase: string, spawn?: NukeSpawn): boolean;
27
40
  export interface OwnerScanDeps {
28
41
  readdir: (path: string) => string[];
29
42
  isDir: (path: string) => boolean;
package/root-nuke.js CHANGED
@@ -7,27 +7,44 @@
7
7
  // prevention half.
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { lstatSync, readdirSync } from "node:fs";
10
- import { basename, dirname, isAbsolute } from "node:path";
10
+ import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
11
11
  const NUKE_IMAGE = "alpine:3";
12
12
  const NUKE_TIMEOUT_MS = 60_000;
13
- /** Refuse a target that would turn the nuke into something catastrophic. The
14
- * mount is the target's PARENT, so a target with no leaf ("/" or a trailing
15
- * slash) means "delete the mount", and a relative path resolves against a cwd
16
- * the container does not share. These are programming errors, not runtime
17
- * conditions: throw loudly rather than run a root rm against a guess. */
18
- export function assertNukeTarget(target) {
13
+ /** Refuse a target that would turn the nuke into something catastrophic.
14
+ *
15
+ * `allowedBase` is the containment: the target must sit strictly INSIDE it.
16
+ * Nothing else here is sufficient on its own, because the worst case passes
17
+ * every structural check. Under `--daemon reuse` the harness's storeDir is the
18
+ * developer's real `~/.norsk-ctl` -- absolute, ordinary leaf, no glob,
19
+ * non-root parent -- and nuking it mounts $HOME into a root container and
20
+ * removes the daemon's entire state: config, products, templates, instances.
21
+ * Today only an early return in teardown keeps the nuke away from it, and a
22
+ * guard that depends on a `return` staying put is not a guard.
23
+ *
24
+ * The base itself is refused too: it is shared by concurrently running tiers,
25
+ * so removing it would delete sibling runs' live stores. */
26
+ export function assertNukeTarget(target, allowedBase) {
19
27
  if (!isAbsolute(target))
20
28
  throw new Error(`refusing to nuke a relative path: ${target}`);
21
29
  const leaf = basename(target);
22
30
  if (leaf === "" || leaf === "." || leaf === "..")
23
31
  throw new Error(`refusing to nuke a path with no leaf: ${target}`);
24
- if (leaf === "*" || leaf.includes("*"))
32
+ if (leaf.includes("*"))
25
33
  throw new Error(`refusing to nuke a glob: ${target}`);
26
34
  // dirname is what gets bind-mounted; "/" would hand the whole host to a root
27
- // container. Every real target (a store under test-temp) is nested deeper.
35
+ // container. Every real target (a store under the temp base) is nested deeper.
28
36
  if (dirname(target) === "/" || dirname(target) === target) {
29
37
  throw new Error(`refusing to nuke a top-level path (its parent would be the mount): ${target}`);
30
38
  }
39
+ // resolve() first so `..` cannot walk out, and compare with a trailing
40
+ // separator so `/x/test-temp-evil` does not pass as inside `/x/test-temp`.
41
+ const t = resolve(target);
42
+ const base = resolve(allowedBase);
43
+ if (t === base)
44
+ throw new Error(`refusing to nuke the temp base itself (concurrent runs live here): ${t}`);
45
+ if (!t.startsWith(base.endsWith(sep) ? base : base + sep)) {
46
+ throw new Error(`refusing to nuke ${t}: outside the test temp base ${base}`);
47
+ }
31
48
  }
32
49
  /** Docker argv for the nuke. Two things matter here:
33
50
  *
@@ -55,14 +72,21 @@ export function nukeArgv(target) {
55
72
  basename(target),
56
73
  ];
57
74
  }
58
- /** Remove `target` as root. Returns whether the nuke actually ran: `spawnSync`
59
- * reports a missing docker in its RESULT rather than throwing, and a caller
60
- * that retries a doomed spawn forever (silently) is the failure mode to avoid.
75
+ /** Remove `target` as root. Returns whether the nuke actually SUCCEEDED.
76
+ *
77
+ * Both halves matter. `spawnSync` reports a missing docker in its RESULT
78
+ * rather than throwing, and a docker that is present but failing -- daemon
79
+ * stopped, image unpullable, mount refused -- reports only a non-zero status
80
+ * with no `error` at all. Reading `error` alone calls that a success, so a
81
+ * caller latching on the return value never latches and re-spawns a doomed
82
+ * 60-second docker on every attempt, forever and silently.
61
83
  * Throws only for an unsafe target -- see {@link assertNukeTarget}. */
62
- export function nukePathAsRoot(target, spawn = spawnSync) {
63
- assertNukeTarget(target);
84
+ export function nukePathAsRoot(target, allowedBase, spawn = spawnSync) {
85
+ assertNukeTarget(target, allowedBase);
64
86
  const r = spawn("docker", nukeArgv(target), { timeout: NUKE_TIMEOUT_MS });
65
- return !r?.error;
87
+ if (r?.error)
88
+ return false;
89
+ return r?.status === 0 || r?.status === undefined;
66
90
  }
67
91
  /** Paths under `dir` owned by a uid that is not `selfUid`, with the owning uid.
68
92
  *
package/smoke.js CHANGED
@@ -28,7 +28,7 @@ import { pollUntil } from "./poll.js";
28
28
  import { nukePathAsRoot, reportForeignOwners } from "./root-nuke.js";
29
29
  import { startSrtSources } from "./source-pump.js";
30
30
  import { applyTestHost, fetchComponentState, fetchComponents, fetchStreamMappings, isSrtListenerState, } from "./studio-state.js";
31
- import { makeStoreDir } from "./temp-dir.js";
31
+ import { makeStoreDir, TEST_TMP_BASE } from "./temp-dir.js";
32
32
  const SMOKE_PORT_BASE = 33000;
33
33
  const SMOKE_BAND_WIDTH = 20;
34
34
  const SMOKE_BANDS = 50;
@@ -62,7 +62,7 @@ export const defaultSmokeDeps = {
62
62
  fetch: (url) => fetch(url, { signal: AbortSignal.timeout(5000) }),
63
63
  },
64
64
  cleanup: (opts) => cleanupDaemon({ ...opts, stopProxy: async () => { }, proxy: false }),
65
- nukeStoreAsRoot: (storeDir) => nukePathAsRoot(storeDir),
65
+ nukeStoreAsRoot: (storeDir) => nukePathAsRoot(storeDir, TEST_TMP_BASE),
66
66
  storeExists: (storeDir) => existsSync(storeDir),
67
67
  ensureNetwork: () => ensureRunnerOnNetwork(),
68
68
  supportsNoPublish: ctlSupportsNoPublish,
package/temp-dir.js CHANGED
@@ -122,7 +122,7 @@ function pruneStaleTempDirs(base) {
122
122
  nukeAsRoot: (p) => {
123
123
  if (nukeKnownUnavailable)
124
124
  return false;
125
- const ran = nukePathAsRoot(p);
125
+ const ran = nukePathAsRoot(p, base);
126
126
  if (!ran)
127
127
  nukeKnownUnavailable = true;
128
128
  return ran;