Ctrl K
ComunidadGuías…

deploy/atomic-write.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { describe, expect, test } from 'bun:test'
import { spawnSync } from 'node:child_process'
import {
  chmodSync,
  existsSync,
  lstatSync,
  mkdirSync,
  readdirSync,
  readFileSync,
  rmSync,
  statSync,
  symlinkSync,
  writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import { guardRealStateDir, installCleanup, mkTempTree } from './tmp-fixtures'

// seed()'s scratch tree is owned by mkTempTree, so the sweep removes it even
// when a test throws mid-run. The per-test try/finally pairs stay (they release
// earlier; the sweep is `force`). See tmp-fixtures.ts.
installCleanup()
// Tripwire for #1101: this suite sources the real lib/common.sh, which carries
// resolve_target_user()'s getent-first resolution — the mechanism that wrote
// into the operator's real ~/.synaptomind. The tripwire fails the run if it moves.
guardRealStateDir()

// write_file_atomically() (deploy/lib/common.sh) is the ONE mechanism every unit
// write goes through — install.sh's install_service, update.sh's refresh_unit and
// its ensure_restart_policy all call it, so a fix cannot reach one entry point
// and miss the others.
//
// It is tested here, directly, rather than through an entry point, because its
// contract is a FUNCTION-level invariant and the way to break it is to fail one
// of its steps:
//
//   the destination moves from the old file to the new one, and never passes
//   through an absent state.
//
// An entry-point harness can only inject a failure the entry point happens to
// reach first (they assert one step each, and the step that fails depends on the
// order the code happens to use); this one injects a failure at EVERY step in a
// single sweep, which is the only way to see the states in between.
//
// Where the invariant came from: task #1094 introduced the shared writer, and the
// #1095 review of it found that a DANGLING symlink at the destination was
// `rm -f`'d BEFORE the four fallible steps, so a failure in any of them — and
// the window spanned the whole touch -> chmod -> cp sequence — left nothing at
// the destination at all. It was a regression rather than an inherited bug:
// GNU cp, which the code it replaced used, refuses a dangling symlink outright
// ("not writing through dangling symlink"), so the old writer left the link in
// place and reported the failure.
//
// No sudo, no systemctl, no network, nothing outside the fixture root: `sudo` is
// a PATH stub that refuses any path outside the root, so the real one is never
// invoked. Skipped when the suite runs as root, because run_root() then calls the
// command directly and the injected failures would not happen at all.
const LIB = join(import.meta.dir, 'lib', 'common.sh')
const APP = 'synaptomind'

const OLD_UNIT = '[Unit]\nDescription=the OLD unit\n'
const NEW_UNIT = '[Unit]\nDescription=the NEW unit\n\n[Service]\nExecStart=/bin/true\n'

/**
 * Every step the replacement cannot do without: the staging file, its mode, the
 * payload and the swap. `rm` is deliberately NOT here — the only rm left is the
 * best-effort cleanup of a staging file left by a dead run, which is followed by
 * `touch` overwriting it anyway, so failing it cannot lose the destination (and
 * before #1096 it was the rm of a DANGLING LINK, which could: that is the very
 * step this suite exists to pin).
 */
const STEPS = ['touch', 'chmod', 'cp', 'mv'] as const

type Kind = 'file' | 'link' | 'dangling'
type Fixture = { root: string; unitFile: string; src: string; stubs: string; target: string }

/**
 * A scratch root with a DESTINATION of the given shape and a payload beside it,
 * plus a `sudo` stub that refuses any absolute path outside the root and fails
 * ONE named primitive on demand (`broken`).
 */
function seed(opts: { kind?: Kind; broken?: string } = {}): Fixture {
  const kind = opts.kind ?? 'file'
  const root = mkTempTree('synapto-atomic-')
  const unitDir = join(root, 'unit')
  const srcDir = join(root, 'src')
  const targetDir = join(root, 'linked')
  const stubs = join(root, 'stubs')
  for (const d of [unitDir, srcDir, targetDir, stubs]) mkdirSync(d, { recursive: true })
  const unitFile = join(unitDir, `${APP}.service`)
  const target = join(targetDir, `${APP}.service`)
  const src = join(srcDir, 'rendered.service')

  writeFileSync(target, OLD_UNIT)
  if (kind === 'file') {
    writeFileSync(unitFile, OLD_UNIT)
  } else {
    symlinkSync(target, unitFile)
    if (kind === 'dangling') rmSync(target)
  }

  writeFileSync(src, NEW_UNIT)
  chmodSync(src, 0o644)
  writeFileSync(
    join(stubs, 'sudo'),
    [
      '#!/usr/bin/env bash',
      'for a in "$@"; do',
      '  case "$a" in',
      // Only ABSOLUTE arguments are checked — a relative one is the command
      // name — and every one of them must live under the fixture root, so this
      // stub cannot reach a path outside it whatever it is asked to do.
      `    /*) case "$a" in ${root}/*) ;; *) echo "STUB: refused path $a" >&2; exit 99 ;; esac ;;`,
      '  esac',
      'done',
      opts.broken
        ? `case " $* " in *" ${opts.broken} "*) echo "stub: ${opts.broken} failed" >&2; exit 1 ;; esac`
        : ':',
      'exec "$@"'
    ].join('\n')
  )
  chmodSync(join(stubs, 'sudo'), 0o755)
  return { root, unitFile, src, stubs, target }
}

/** Run write_file_atomically against the fixture, with the stub on PATH. */
function write(fx: Fixture, mode?: string) {
  // MODE is passed as a third ARGUMENT only when the caller supplies one, the
  // way install.sh and update.sh call it (they inherit the mode instead).
  const script = '. "$1"\nwrite_file_atomically "$2" "$3" ' + (mode ? '"$4"' : '')
  const res = spawnSync(
    'bash',
    ['-c', script, 'write_file_atomically', LIB, fx.unitFile, fx.src, ...(mode ? [mode] : [])],
    {
      encoding: 'utf8',
      env: { ...process.env, PATH: `${fx.stubs}:${process.env.PATH}`, APP_NAME: APP },
      timeout: 30_000
    }
  )
  return { status: res.status, stdout: res.stdout, stderr: res.stderr }
}

/** What is at the destination, as an operator would find it. */
function destState(fx: Fixture): 'absent' | 'dangling link' | 'file' {
  const st = lstatSync(fx.unitFile, { throwIfNoEntry: false })
  if (!st) return 'absent'
  if (st.isSymbolicLink() && !existsSync(fx.unitFile)) return 'dangling link'
  return 'file'
}

/** Staging litter in the destination's directory (a name systemd never loads). */
function leftovers(fx: Fixture): string[] {
  return readdirSync(join(fx.root, 'unit')).filter(f => f.includes('.new.'))
}

const isRoot = typeof process.getuid === 'function' && process.getuid() === 0

describe.skipIf(isRoot)('write_file_atomically — the destination is never absent', () => {
  // The direction test for #1096 F2: against ebb26c8 this fails for touch, chmod,
  // cp and mv, each with 'absent'.
  test('a failure at EVERY step leaves a dangling link exactly where it was', () => {
    for (const broken of STEPS) {
      const fx = seed({ kind: 'dangling', broken })
      try {
        const res = write(fx)
        expect(res.status, `${broken}: the write must be refused`).not.toBe(0)
        // THE INVARIANT. Not "the write failed" — the destination is still
        // there, exactly as the run found it.
        expect(destState(fx), `${broken}: the destination must not pass through absent`).toBe(
          'dangling link'
        )
        // And the report says what is really there, not "it does not exist".
        expect(res.stderr, `${broken}: the report must name the real state`).toContain(
          'dangling link'
        )
        expect(res.stderr, `${broken}: it must not claim the file is gone`).not.toContain(
          'it does not exist'
        )
        expect(leftovers(fx), `${broken}: no staging litter`).toEqual([])
      } finally {
        rmSync(fx.root, { recursive: true, force: true })
      }
    }
  })

  test('a failure at EVERY step leaves a regular destination byte-identical', () => {
    // The general form of the invariant, and the case the entry-point suites
    // cover end to end: the destination is whatever it was, at whatever mode.
    for (const broken of STEPS) {
      const fx = seed({ kind: 'file', broken })
      try {
        const res = write(fx)
        expect(res.status, `${broken}: the write must be refused`).not.toBe(0)
        expect(destState(fx), `${broken}: the destination must not pass through absent`).toBe(
          'file'
        )
        expect(readFileSync(fx.unitFile, 'utf8'), `${broken}: byte-identical`).toBe(OLD_UNIT)
        expect(leftovers(fx), `${broken}: no staging litter`).toEqual([])
      } finally {
        rmSync(fx.root, { recursive: true, force: true })
      }
    }
  })

  test('a successful write over a dangling link installs the new unit', () => {
    // The end shape the removed `rm -f` was there to produce, now reached by the
    // rename alone: a regular file at the destination carrying the new body, with
    // the link gone. The caller passes no mode — as install.sh and update.sh do —
    // so the payload's own mode is what lands.
    const fx = seed({ kind: 'dangling' })
    try {
      const res = write(fx)
      expect(res.status, res.stderr).toBe(0)
      expect(destState(fx)).toBe('file')
      expect(readFileSync(fx.unitFile, 'utf8')).toBe(NEW_UNIT)
      expect(statSync(fx.unitFile).mode & 0o777).toBe(0o644)
      expect(res.stdout).toContain('is a dangling link; replaced it with a regular file')
      expect(leftovers(fx)).toEqual([])
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('a live link is still written THROUGH, never replaced', () => {
    // The #1094 decision, pinned beside the dangling case so the two branches
    // cannot be swapped: a link with a live target keeps its shape, because
    // replacing it would orphan the file systemd is actually loading — and
    // `stat` without -L would report the link's own mode, always 777.
    const fx = seed({ kind: 'link' })
    try {
      const res = write(fx)
      expect(res.status, res.stderr).toBe(0)
      expect(lstatSync(fx.unitFile).isSymbolicLink(), 'the link must survive').toBe(true)
      expect(readFileSync(fx.target, 'utf8')).toBe(NEW_UNIT)
      expect(res.stdout).toContain('is a link; wrote through it to')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })
})