@norskvideo/ctl-test-harness 0.1.35 → 0.1.36

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/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,
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.36",
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
@@ -23,7 +31,7 @@ export declare function nukeArgv(target: string): string[];
23
31
  * reports a missing docker in its RESULT rather than throwing, and a caller
24
32
  * that retries a doomed spawn forever (silently) is the failure mode to avoid.
25
33
  * Throws only for an unsafe target -- see {@link assertNukeTarget}. */
26
- export declare function nukePathAsRoot(target: string, spawn?: NukeSpawn): boolean;
34
+ export declare function nukePathAsRoot(target: string, allowedBase: string, spawn?: NukeSpawn): boolean;
27
35
  export interface OwnerScanDeps {
28
36
  readdir: (path: string) => string[];
29
37
  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
  *
@@ -59,8 +76,8 @@ export function nukeArgv(target) {
59
76
  * reports a missing docker in its RESULT rather than throwing, and a caller
60
77
  * that retries a doomed spawn forever (silently) is the failure mode to avoid.
61
78
  * Throws only for an unsafe target -- see {@link assertNukeTarget}. */
62
- export function nukePathAsRoot(target, spawn = spawnSync) {
63
- assertNukeTarget(target);
79
+ export function nukePathAsRoot(target, allowedBase, spawn = spawnSync) {
80
+ assertNukeTarget(target, allowedBase);
64
81
  const r = spawn("docker", nukeArgv(target), { timeout: NUKE_TIMEOUT_MS });
65
82
  return !r?.error;
66
83
  }
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;