/** * Ownership — the tree-wide chown every install and update ends with * (deploy/lib/common.sh §Ownership: ownership_mismatch, chown_target, * apply_ownership, apply_run_dir_ownership). * * WHY THIS FILE EXISTS, AND WHY IT IS NOT PART OF THE PER-TARGET SUITE. The * apply_ownership tests in app-env-conformance.test.ts (added by #1108) install a * `stat` stub that answers `%U:%G` with `root:root`. That makes the fixture tree * look root-owned, which puts the run on the FIRST line of ownership_mismatch — * `stat` disagrees with the target spec, so it answers "mismatch" there and the * `find` walk below it never runs. Every one of those tests therefore passes * identically against the pre-#1121 implementation, which made the same * `stat`-alone decision inline in apply_ownership. A green suite, and a defect * that survived it: #1114 left 387 root-owned entries under /opt/subagentix * while the top directory itself read opencode:opencode, including .git/index, * which is what a `git status` as the service user fails on. * * So the tests here are built to REACH the tree walk, which means the fixture * must contain an entry whose ownership genuinely differs from the top * directory's — and that has to be arranged without root. See * `secondaryGroup` for the only mechanism that works for an unprivileged * process, and for why it is the group axis. * * WHAT IS ASSERTED, AND WHERE THE BOUNDARY IS. These are function-level tests * (the same shape as atomic-write.test.ts) because the decision under test — * "which paths does this chown, to which spec, or none at all" — is a property * of the function, and no full install run can arrange a half-foreign tree under * INSTALL_DIR. The privilege boundary itself (run_root → sudo) is a recording * stub in most of these tests, because a real chown to another user needs * privilege this suite must not use. ONE test ("a foreign-owned child is * repaired") lets the real chown run, and that is safe precisely because the * target owner is the test runner: an owner may always chgrp a file it owns to * a group it belongs to, so the repair is exercised without elevating. * * No systemctl, no network, nothing outside the fixture root. `sudo` is a PATH * stub that refuses any absolute argument outside the root — the same * containment atomic-write.test.ts uses. */ import { describe, expect, test } from 'bun:test' import { spawnSync } from 'node:child_process' import { chownSync, chmodSync, copyFileSync, mkdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { guardRealStateDir, installCleanup, mkTempTree } from './tmp-fixtures' installCleanup() guardRealStateDir() const LIB = join(import.meta.dir, 'lib', 'common.sh') const APP = 'synaptomind' // ── Who the runner is, as stat(1)/find(1)/chown(1) name them ────────────────── // // TARGET_USER/TARGET_GROUP are what resolve_target_user() produces on this host, // and ownership_mismatch compares NAMES (stat -c %U:%G), not ids — so the tests // have to speak the same names the tools print. Probed rather than read from // $USER/$GROUP, which a test runner need not export. const ME = command(['id', '-un']) const MY_GROUP = command(['id', '-gn']) /** The absolute path of the real `stat`, for stubs that must forward to it. */ const REAL_STAT = command(['bash', '-c', 'command -v stat']) /** The absolute path of the real `find`, ditto. */ const REAL_FIND = command(['bash', '-c', 'command -v find']) /** * The absolute path of bash itself, because one test hands the spawn a PATH that * holds no shell at all (that is the point of it) and execvp resolves the * interpreter through the child's PATH. */ const REAL_BASH = command(['bash', '-c', 'command -v bash']) function command(argv: string[]): string { const res = spawnSync(argv[0], argv.slice(1), { encoding: 'utf8', timeout: 10_000 }) return (res.stdout ?? '').trim() } type Run = { status: number | null; stdout: string; stderr: string } type RunOpts = { /** The framework function to call, with its arguments. */ call: string[] /** Environment for the spawn. PATH is built by `stubs` unless `env` carries one. */ env?: Record /** A lib to source instead of the shipped one (mutations only). */ lib?: string /** Directories prepended to PATH, in order. */ stubs?: string[] /** Record run_root instead of crossing the privilege boundary (default true). */ spyRoot?: boolean /** Exit status the recorded run_root returns; non-zero exercises the warning. */ spyRc?: number } /** * Source the real lib/common.sh and call one function, recording the chowns. * * `set -u` and NOT `set -e`. The framework's entry points run `set -euo * pipefail`, and the -u half is load-bearing here: both apply_ownership and * apply_run_dir_ownership document that they must not abort on an unset * INSTALL_DIR/DATA_DIR/RUN_DIR, and that claim is only testable under -u. The * -e half would be actively harmful: ownership_mismatch RETURNS 1 for "already * owned", so -e would turn the function's own answer into an early abort and the * harness could not observe a return value at all. */ function runOwnership(opts: RunOpts): Run { const prelude = ['set -uo pipefail', '. "$1"', 'shift'] if (opts.spyRoot !== false) { prelude.push( // The privilege boundary, recorded rather than crossed. run_root is // `sudo "$@"` for a non-root caller, and the DECISION under test (which // path, which spec, or none at all) is entirely upstream of it. 'run_root() { printf \'RUN_ROOT %s\\n\' "$*"; return "${OWNERSHIP_SPY_RC:-0}"; }' ) } const path = [...(opts.stubs ?? []), process.env.PATH].join(':') const res = spawnSync( REAL_BASH, ['-c', [...prelude, '"$@"'].join('\n'), 'ownership', opts.lib ?? LIB, ...opts.call], { encoding: 'utf8', env: { ...(process.env as Record), PATH: path, APP_NAME: APP, TARGET_USER: ME, TARGET_GROUP: MY_GROUP, OWNERSHIP_SPY_RC: String(opts.spyRc ?? 0), ...opts.env } as Record, timeout: 30_000 } ) return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' } } /** Every chown the harness recorded, parsed out of `RUN_ROOT chown -R spec path`. */ function chowns(res: Run): { spec: string; path: string }[] { return res.stdout .split('\n') .filter(l => l.startsWith('RUN_ROOT chown')) .map(l => l.replace('RUN_ROOT chown -R ', '').split(' ')) .map(([spec, path]) => ({ spec, path })) } /** Just the paths, which is what most of these assertions read. */ function chownedPaths(res: Run): string[] { return chowns(res).map(c => c.path) } // ── Fixtures ───────────────────────────────────────────────────────────────── /** * A group the runner belongs to that is NOT its own primary group, or null. * * WHY THE GROUP AXIS IS THE ONLY ONE AVAILABLE. The mismatch these tests need is * "the top directory is right and something beneath it is wrong" — the #1114 * shape. The top directory has to pass the `stat -c %U:%G` gate first, so it must * genuinely belong to TARGET_USER:TARGET_GROUP, i.e. to the runner. That leaves * one way to make a child disagree without root: * * - USER: chown to another user always needs root, and a hardlink to a * root-owned file would carry root's ownership for free — but * protected_hardlinks=1 (the default) refuses exactly that. Both were tried * on the dev host: `ln /etc/hostname ` → "Operation not permitted". * - GROUP: chgrp to a group you are a member of is permitted, and a setgid * directory propagates its group to the files created inside it. So a setgid * subdirectory chgrp'd to a secondary group, holding one file, is a real * `find ! -group` hit built with no privilege at all. * * The consequence is a host dependency, and it is stated rather than hidden: on a * host whose runner belongs to no secondary group, the two group-dependent * describes report `skip` — not a vacuous pass. */ function secondaryGroup(): { name: string; gid: number } | null { const mine = command(['id', '-g']) for (const gid of command(['id', '-G']).split(/\s+/).filter(Boolean)) { if (gid === mine) continue const name = command(['getent', 'group', gid]).split(':')[0] if (name) return { name, gid: Number(gid) } } return null } const FOREIGN = secondaryGroup() /** * `dir/rel`, holding one file, owned by ME but NOT by the target's group. * * `chownSync(path, -1, gid)` is the documented "leave the user alone" form. The * setgid bit is what makes the FILE land in the foreign group too: without it a * new file takes the creator's primary group and the walk finds nothing. */ function seedForeignOwnedEntry(dir: string, rel: string, group: { gid: number }): string { const sub = join(dir, rel) mkdirSync(sub, { recursive: true }) chownSync(sub, -1, group.gid) chmodSync(sub, 0o2775) const file = join(sub, 'payload') writeFileSync(file, 'payload\n') return file } /** A `stat` that reports `root:root` for `%U:%G` and forwards everything else. */ function seedForeignOwnerStat(root: string): string { const stubs = join(root, 'stat-stubs') mkdirSync(stubs, { recursive: true }) writeFileSync( join(stubs, 'stat'), [ '#!/usr/bin/env bash', 'for a in "$@"; do', // Only %U:%G is intercepted. %a (mode) is what the unit writer uses to // preserve an operator's 600, and forwarding the rest keeps that path real. ' if [ "$a" = "%U:%G" ]; then printf \'root:root\\n\'; exit 0; fi', 'done', `exec ${JSON.stringify(REAL_STAT)} "$@"` ].join('\n') ) chmodSync(join(stubs, 'stat'), 0o755) return stubs } /** A `sudo` that records its argv and then really runs the command. */ function seedSudoStub(root: string): { stubs: string; log: string } { const stubs = join(root, 'sudo-stubs') const log = join(root, 'sudo.log') mkdirSync(stubs, { recursive: true }) writeFileSync(log, '') writeFileSync( join(stubs, 'sudo'), [ '#!/usr/bin/env bash', 'for a in "$@"; do printf \' %s\' "$a" >> "$SUDO_LOG"; done', 'printf \'\\n\' >> "$SUDO_LOG"', 'for a in "$@"; do', ' case "$a" in', ` /*) case "$a" in ${root}/*) ;; *) echo "STUB: refused path $a" >&2; exit 99 ;; esac ;;`, ' esac', 'done', // `exec`, so the chown really happens: what is under test is the shipped // `chown -R ` and its EFFECT, and a stub that swallowed the // command would prove nothing about the repair. 'exec "$@"' ].join('\n') ) chmodSync(join(stubs, 'sudo'), 0o755) return { stubs, log } } /** * A `find` that records that it was called and then really runs. * * The recording is the point. "A chown was issued" and "the tree was walked" are * different claims: ownership_mismatch can answer "mismatch" from its FIRST line * (the stat gate), so a test that asserted only the chown would pass against an * implementation that never looks at the tree — which is the pre-#1121 defect. */ function seedFindWrapper(root: string): { stubs: string; log: string } { const stubs = join(root, 'find-stubs') const log = join(root, 'find.log') mkdirSync(stubs, { recursive: true }) writeFileSync(log, '') writeFileSync( join(stubs, 'find'), [ '#!/usr/bin/env bash', 'printf \'%s\\n\' "$*" >> "$FIND_LOG"', `exec ${JSON.stringify(REAL_FIND)} "$@"` ].join('\n') ) chmodSync(join(stubs, 'find'), 0o755) return { stubs, log } } /** A `find` that always fails, as one without `-quit` support does. */ function seedFailingFind(root: string): string { const stubs = join(root, 'failfind-stubs') mkdirSync(stubs, { recursive: true }) writeFileSync( join(stubs, 'find'), ['#!/usr/bin/env bash', 'echo "find: unsupported" >&2', 'exit 1'].join('\n') ) chmodSync(join(stubs, 'find'), 0o755) return stubs } /** * A copy of lib/common.sh with `ownership_mismatch` reduced to the pre-#1121 * decision: the top directory alone, via `stat`. * * NEVER the repo's file. The mutation exists only to prove a neighbouring * assertion can fail — the technique install.sh.test.ts's mutateInstallScript * uses. Throwing when the target text is absent is deliberate: a reformat would * otherwise leave the mutation a silent no-op and the non-vacuity test would * quietly stop testing anything. */ function seedTopDirOnlyOwnership(root: string): string { const lib = join(root, 'lib') mkdirSync(lib, { recursive: true }) const copy = join(lib, 'common.sh') copyFileSync(LIB, copy) const src = readFileSync(copy, 'utf8') const from = `ownership_mismatch() { local p="$1" hit rc=0 [ "$(stat -c '%U:%G' "$p" 2>/dev/null || true)" = "$(ownership_target)" ] || return 0 command -v find >/dev/null 2>&1 || return 0 hit="$(find "$p" \\( ! -user "\${TARGET_USER}" -o ! -group "\${TARGET_GROUP:-\${TARGET_USER}}" \\) \\ -print -quit 2>/dev/null)" || rc=$? [ "$rc" -eq 0 ] || return 0 [ -n "$hit" ] }` const to = `ownership_mismatch() { [ "$(stat -c '%U:%G' "$1" 2>/dev/null || true)" != "$(ownership_target)" ] }` if (!src.includes(from)) { throw new Error('mutation target absent from common.sh: the ownership_mismatch body moved') } writeFileSync(copy, src.replace(from, to)) return copy } /** A tree entirely ME's, the way a non-root install into your own home leaves it. */ function seedOwnedTree(root: string, rel: string): string { const dir = join(root, rel) mkdirSync(join(dir, 'sub'), { recursive: true }) writeFileSync(join(dir, 'app'), 'x\n') writeFileSync(join(dir, 'sub', 'index'), 'x\n') return dir } // ── apply_ownership: the tree walk ─────────────────────────────────────────── describe('ownership_mismatch — a correct top directory is not the question', () => { test.skipIf(FOREIGN === null)( 'a foreign-owned child under a clean top directory is repaired by the real chown', () => { // The #1114 case, end to end: the top directory reads ME:ME (so the stat // gate PASSES and the walk is what decides), the subtree beneath it does // not, and the shipped `chown -R ME:ME` puts it right. The pre-#1121 // implementation skipped this tree entirely. const root = mkTempTree('synapto-own-') const tree = seedOwnedTree(root, 'opt') const child = seedForeignOwnedEntry(tree, 'sub', FOREIGN!) const { stubs, log } = seedSudoStub(root) const find = seedFindWrapper(root) expect(statSync(child).gid, 'the fixture must start out mismatched').not.toBe( statSync(tree).gid ) const res = runOwnership({ call: ['apply_ownership'], stubs: [find.stubs, stubs], spyRoot: false, env: { FIND_LOG: find.log, SUDO_LOG: log, INSTALL_DIR: tree, DATA_DIR: join(root, 'absent-data') } }) expect(res.status, res.stdout + res.stderr).toBe(0) // The walk was reached, not short-circuited by the stat gate. expect(readFileSync(find.log, 'utf8'), 'find was never consulted').toContain(tree) // The chown named the tree — and the REAL chown ran, through the stub. expect(readFileSync(log, 'utf8'), res.stdout).toContain(`chown -R ${ME}:${MY_GROUP} ${tree}`) // And the tree is actually repaired, which an argv assertion alone cannot show. expect(statSync(child).gid, 'the child is still in the foreign group').toBe( statSync(tree).gid ) } ) test('a fully owned tree is NOT chowned — the walk finds nothing', () => { // The other half of the same decision, and the one that keeps the "say // nothing" contract honest: a non-root install into the caller's own // directory must not pay a recursive chown over a large node_modules on every // run. This test is also what proves the stat gate PASSES for an ME:ME tree, // which is the premise both tests below rely on. const root = mkTempTree('synapto-own-') const tree = seedOwnedTree(root, 'opt') const find = seedFindWrapper(root) const res = runOwnership({ call: ['apply_ownership'], stubs: [find.stubs], env: { FIND_LOG: find.log, INSTALL_DIR: tree, DATA_DIR: join(root, 'absent-data') } }) expect(res.status, res.stdout + res.stderr).toBe(0) expect(readFileSync(find.log, 'utf8'), 'find was never consulted').toContain(tree) expect(res.stdout, 'a no-op recursive chown was issued').not.toContain('RUN_ROOT chown') expect(res.stdout, 'and nothing at all was reported').toBe('') }) test.skipIf(FOREIGN === null)( 'against the pre-#1121 top-directory-only decision the same tree gets NO chown', () => { // THE NON-VACUITY PROOF. Without it the test above could pass for the // wrong reason: an implementation that chowned unconditionally would // satisfy it too, and so would the old one on a different fixture. Here the // same tree, the same walk and the same recorder run against a mutated copy // whose ownership_mismatch is the stat-alone predicate — and it must produce // nothing. If this test ever fails, the mutation stopped matching the // shipped body and the test above is no longer proving anything. const root = mkTempTree('synapto-own-') const tree = seedOwnedTree(root, 'opt') seedForeignOwnedEntry(tree, 'sub', FOREIGN!) const lib = seedTopDirOnlyOwnership(root) const find = seedFindWrapper(root) const res = runOwnership({ call: ['apply_ownership'], lib, stubs: [find.stubs], env: { FIND_LOG: find.log, INSTALL_DIR: tree, DATA_DIR: join(root, 'absent-data') } }) expect(res.status, res.stdout + res.stderr).toBe(0) expect(res.stdout, 'the top-directory-only decision still chowned the tree').not.toContain( 'RUN_ROOT chown' ) } ) }) describe('ownership_mismatch — an unverifiable tree is a mismatch', () => { test('a find that fails is a MISMATCH, so the tree is chowned rather than trusted', () => { // Degraded behaviour by design: a find without -quit support, or one that // cannot descend, exits non-zero. The alternative — reading an unverifiable // tree as clean — is the failure #1114 was, one indirection further away. const root = mkTempTree('synapto-own-') const tree = seedOwnedTree(root, 'opt') const res = runOwnership({ call: ['apply_ownership'], stubs: [seedFailingFind(root)], env: { INSTALL_DIR: tree, DATA_DIR: join(root, 'absent-data') } }) expect(res.status, res.stdout + res.stderr).toBe(0) expect(chownedPaths(res), 'a failing find must not be read as "clean"').toEqual([tree]) // The premise shared with 'a fully owned tree is NOT chowned': the stat gate // passed, so the mismatch can only have come from the walk. expect(res.stdout).toContain(`RUN_ROOT chown -R ${ME}:${MY_GROUP} ${tree}`) }) test('no find at all is a MISMATCH too', () => { // The last degraded path, and the one the `command -v find` guard exists for. // PATH is reduced to a directory holding only `stat`, so it is the guard in // front of the walk that is being exercised: the top directory is still // ME:ME, so the stat gate passes and the missing find is what makes the tree // unverifiable. const root = mkTempTree('synapto-own-') const tree = seedOwnedTree(root, 'opt') const stubs = join(root, 'bare-stubs') mkdirSync(stubs, { recursive: true }) // A symlink, not a `#!/usr/bin/env bash` script: PATH has no shell in it, so // a script stub could not even start. symlinkSync(REAL_STAT, join(stubs, 'stat')) const res = runOwnership({ call: ['apply_ownership'], env: { PATH: stubs, INSTALL_DIR: tree, DATA_DIR: join(root, 'absent-data') } }) expect(res.status, res.stdout + res.stderr).toBe(0) expect(chownedPaths(res)).toEqual([tree]) }) }) describe('apply_ownership — both trees', () => { test('INSTALL_DIR and DATA_DIR are each chowned to the target spec', () => { // The two trees the gap was about: the payload the service reads, and the // directory it writes to. The stat stub is what puts both on the "the top // directory itself disagrees" branch — the same trick // app-env-conformance.test.ts uses, and the only way to reach that branch // unprivileged. const root = mkTempTree('synapto-own-') const install = seedOwnedTree(root, 'opt') const data = seedOwnedTree(root, 'data') const res = runOwnership({ call: ['apply_ownership'], stubs: [seedForeignOwnerStat(root)], env: { INSTALL_DIR: install, DATA_DIR: data } }) expect(res.status, res.stdout + res.stderr).toBe(0) expect(chownedPaths(res), 'both trees, in order').toEqual([install, data]) // To the pair the unit runs as — not root, and not an empty field. for (const { spec } of chowns(res)) expect(spec).toBe(`${ME}:${MY_GROUP}`) }) test('an unset DATA_DIR is skipped, not fatal, and the pass continues', () => { // A host whose app.env omits DATA_DIR must not abort the install here over a // key it cannot set: setup_data() already tolerates exactly that, and this // runs before the unit exists. Only `set -u` can catch such an abort, which // is why runOwnership sets it. const root = mkTempTree('synapto-own-') const install = seedOwnedTree(root, 'opt') const res = runOwnership({ call: ['apply_ownership'], env: { INSTALL_DIR: install } }) expect(res.status, res.stdout + res.stderr).toBe(0) expect(res.stderr, 'set -u must not abort on an unset DATA_DIR').not.toContain( 'unbound variable' ) }) }) describe('chown_target — a chown that fails is reported, and is never fatal', () => { test('a refused chown warns with the consequence and the fix, and returns 0', () => { // Not fatal by decision (ADR §3c): a run without root and without sudo cannot // chown anything to another user, and that is a legitimate configuration // (--no-service in a container, a --dir install under your own home). Aborting // would turn a cosmetic ownership difference into a failed install. const root = mkTempTree('synapto-own-') const tree = seedOwnedTree(root, 'opt') const res = runOwnership({ call: ['apply_ownership'], stubs: [seedForeignOwnerStat(root)], spyRc: 1, env: { INSTALL_DIR: tree, DATA_DIR: join(root, 'absent-data') } }) expect(res.status, res.stdout + res.stderr).toBe(0) // The attempt stays visible, so "warned" and "never tried" cannot be confused. expect(res.stdout).toContain('RUN_ROOT chown') expect(res.stderr).toContain('cannot chown') expect(res.stderr).toMatch(/EACCES/) expect(res.stderr).toMatch(/fix: sudo chown/) }) }) // ── apply_run_dir_ownership ────────────────────────────────────────────────── describe('apply_run_dir_ownership — RUN_DIR, scripts/ and hooks/ only', () => { test('all three paths are chowned, and nothing else under RUN_DIR is', () => { // The contract is a list of three paths, not a `chown -R ${RUN_DIR}`: RUN_DIR // may also hold app state the framework does not own (a legacy data/, a // settings.json), and a blind recursive chown would silently take that too — // so `data/` is given a child of its own and must be left alone. const root = mkTempTree('synapto-own-') const runDir = join(root, 'run') for (const d of ['scripts', 'hooks', 'data']) mkdirSync(join(runDir, d), { recursive: true }) // The mode-600 app.env the pre-update hook has to READ as TARGET_USER: the // file #1113's database backup could not read. writeFileSync(join(runDir, 'scripts', 'app.env'), 'INSTALL_DIR=/opt/x\n') chmodSync(join(runDir, 'scripts', 'app.env'), 0o600) const res = runOwnership({ call: ['apply_run_dir_ownership'], stubs: [seedForeignOwnerStat(root)], env: { RUN_DIR: runDir } }) expect(res.status, res.stdout + res.stderr).toBe(0) expect(chownedPaths(res), 'the three documented paths, in order').toEqual([ runDir, join(runDir, 'scripts'), join(runDir, 'hooks') ]) expect(res.stdout, "RUN_DIR/data is not the framework's to chown").not.toContain( join(runDir, 'data') ) }) test.skipIf(FOREIGN === null)( 'a foreign-owned child under scripts/ is found, and hooks/ is left alone', () => { // The tree walk again, this time inside RUN_DIR: a foreign-owned entry under // scripts/ while hooks/ is clean must still produce a chown, and hooks/ must // not get one. RUN_DIR itself IS chowned as well, and that is correct rather // than a leak: the walk is recursive, so RUN_DIR contains the mismatch, and // RUN_DIR is one of the three documented paths. const root = mkTempTree('synapto-own-') const runDir = join(root, 'run') mkdirSync(join(runDir, 'hooks'), { recursive: true }) mkdirSync(join(runDir, 'scripts'), { recursive: true }) seedForeignOwnedEntry(join(runDir, 'scripts'), 'node_modules', FOREIGN!) const find = seedFindWrapper(root) const res = runOwnership({ call: ['apply_run_dir_ownership'], stubs: [find.stubs], env: { FIND_LOG: find.log, RUN_DIR: runDir } }) expect(res.status, res.stdout + res.stderr).toBe(0) expect( chownedPaths(res), 'the mismatched path and its scanned parent, in loop order' ).toEqual([runDir, join(runDir, 'scripts')]) expect(readFileSync(find.log, 'utf8')).toContain(join(runDir, 'scripts')) } ) test('an empty RUN_DIR returns 0 without tripping set -u', () => { // Both new app.envs carry `RUN_DIR=""` (the documented "derive from HOME" // form) on a host that never reached resolve_target_user. The guard is // BEFORE the loop on purpose: the loop list is expanded when the `for` runs, // so a test inside the body would be reached only after "${RUN_DIR}/scripts" // had already aborted the script. const res = runOwnership({ call: ['apply_run_dir_ownership'], env: { RUN_DIR: '' } }) expect(res.status, res.stdout + res.stderr).toBe(0) expect(res.stderr).not.toContain('unbound variable') expect(res.stdout).not.toContain('RUN_ROOT chown') }) test('an absent hooks/ is skipped and the other two are still chowned', () => { // A --no-service run, or a host with no hooks installed, must not abort the // pass over the paths that do exist. const root = mkTempTree('synapto-own-') const runDir = join(root, 'run') mkdirSync(join(runDir, 'scripts'), { recursive: true }) const res = runOwnership({ call: ['apply_run_dir_ownership'], stubs: [seedForeignOwnerStat(root)], env: { RUN_DIR: runDir } }) expect(res.status, res.stdout + res.stderr).toBe(0) expect(chownedPaths(res)).toEqual([runDir, join(runDir, 'scripts')]) }) }) // ── The shipped source, not a copy of it ───────────────────────────────────── describe('lib/common.sh — the ownership helpers are the ones under test', () => { test('every helper this file calls is defined in the shipped lib', () => { // A renamed or dropped helper would otherwise surface as "command not found" // in one spawn's stderr, which a status-0 assertion elsewhere could hide. const helpers = [ 'ownership_target', 'ownership_mismatch', 'chown_target', 'apply_ownership', 'apply_run_dir_ownership' ] const res = runOwnership({ call: ['declare', '-F', ...helpers] }) for (const fn of helpers) expect(res.stdout, `${fn} is not defined in ${LIB}`).toContain(fn) }) test('the shipped lib passes bash -n', () => { const res = spawnSync('bash', ['-n', LIB], { encoding: 'utf8' }) expect(res.status, res.stderr).toBe(0) }) })