forjar 1.29.0

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
//! The archive script itself — the one thing on this fleet that deletes originals.
//!
//! # Why this is generated rather than shipped as a file
//!
//! `machines/unas/nas-move.sh` was reviewed on 2026-08-21 and six defects were
//! found, four of them in the verify-then-delete path. They are not exotic; they
//! are the ordinary failure modes of hand-written shell, and every machine that
//! copies the script inherits all six. Generating it means the fix is made once,
//! in a place with tests, and `forjar apply` redeploys it everywhere.
//!
//! Each guard below cites the defect it exists for.

use crate::core::shell_escape::sh_squote;
use crate::core::types::{NasArchive, SMALL_FILE_BYTES};

/// Emit the archive script for one declared resource.
pub fn archive_script(a: &NasArchive) -> String {
    let dirs = a
        .dirs
        .iter()
        .map(|d| sh_squote(d))
        .collect::<Vec<_>>()
        .join(" ");

    format!(
        r#"#!/usr/bin/env bash
# GENERATED BY forjar — do not edit. Source: nas_archive resource.
#
# Moves declared directories from SRC_ROOT to DEST_ROOT and deletes the
# originals only after proving the copy is byte-identical.
set -euo pipefail

SRC_ROOT={src}
DEST_ROOT={dest}
DIRS=({dirs})
MAX_SMALL_BYTES={max_small_bytes}
MIN_AGE_DAYS={min_age}
LEAVE_SYMLINK={leave_symlink}
EXECUTE="${{ARCHIVE_EXECUTE:-0}}"

log() {{ printf '%s\n' "$*"; }}
die() {{ printf 'FATAL: %s\n' "$*" >&2; exit 1; }}
skip() {{ printf 'SKIP %s: %s\n' "$1" "$2"; }}

# The destination must be genuinely writable, PROVEN BY WRITING. A CIFS mount
# whose server has gone keeps reporting a source while every operation fails,
# so `mountpoint -q` is not evidence — this fleet has been caught by exactly
# that.
# The tools this pass depends on must exist BEFORE anything is touched.
# Without this the run gets as far as creating the destination directory and
# then dies on `rsync: command not found` — an operator sees a half-made
# destination and a message about a missing binary, in that order.
for tool in rsync find; do
  command -v "$tool" >/dev/null 2>&1 \
    || die "$tool is not installed — refusing to archive (the verify-before-delete guard needs it)"
done

[ -d "$DEST_ROOT" ] || die "$DEST_ROOT does not exist"
probe="$DEST_ROOT/.forjar-archive-probe.$$"
: > "$probe" 2>/dev/null || die "$DEST_ROOT is not writable — refusing to archive"
rm -f "$probe"

archived=0
skipped=0

for d in "${{DIRS[@]}}"; do
  src="$SRC_ROOT/$d"
  dest="$DEST_ROOT/$d"

  # Already archived: a symlink into the destination is the converged state.
  if [ -L "$src" ]; then
    log "OK $d already a symlink"
    continue
  fi
  [ -d "$src" ] || {{ skip "$d" "no such directory"; skipped=$((skipped+1)); continue; }}

  # ── Defect M6: CIFS collapses on small files ─────────────────────────────
  # Measured on this fleet: ~350 MB/s for large files, ~7.9 MB/s for small ones.
  # What that costs is a function of the BYTES held in small files, not of how
  # many files there are or what share of the count they represent.
  #
  # Both of those proxies were tried and both misfire on real data.
  # /home/noah/data/courses is 755 G in 7,426 files, 46% under 64 KiB — which
  # weighs out as 23.4 MB small against 754.9 GB large: ~3 seconds inside a
  # ~36-minute move. A file-count ceiling refused it; a percentage passed it for
  # the wrong reason. Neither was measuring the thing that costs.
  small_bytes=$(find "$src" -type f -size -{small_bytes}c -printf '%s\n' 2>/dev/null \
                | awk '{{s+=$1}} END {{print s+0}}')
  if [ "$small_bytes" -gt "$MAX_SMALL_BYTES" ]; then
    skip "$d" "$(( small_bytes / 1048576 )) MB in files under {small_bytes}B exceeds archive_max_small_bytes=$(( MAX_SMALL_BYTES / 1048576 )) MB"
    skipped=$((skipped+1)); continue
  fi

  # ── Live data ────────────────────────────────────────────────────────────
  # Archival deletes the source; data still being written belongs to someone
  # who has not finished with it.
  if [ "$MIN_AGE_DAYS" -gt 0 ]; then
    recent=$(find "$src" -type f -mtime "-$MIN_AGE_DAYS" -print -quit 2>/dev/null)
    if [ -n "$recent" ]; then
      skip "$d" "modified within $MIN_AGE_DAYS days"
      skipped=$((skipped+1)); continue
    fi
  fi

  # ── Defect M3: an open file must not be moved ────────────────────────────
  # `lsof` exits 1 when it finds nothing, which under `set -e` aborted the
  # whole script BEFORE the delete rather than skipping this directory. Test
  # the OUTPUT, and keep the non-zero exit off the assignment.
  if command -v lsof >/dev/null 2>&1; then
    open_files=$(lsof +D "$src" -Fn 2>/dev/null || true)
    if [ -n "$open_files" ]; then
      skip "$d" "files are open under it"
      skipped=$((skipped+1)); continue
    fi
  fi

  # ── Defect M5: record the source inventory BEFORE copying ────────────────
  # Without this there is nothing to compare the destination against, so a
  # partial copy and a complete one look identical at verify time.
  before=$(mktemp); trap 'rm -f "$before"' EXIT
  ( cd "$src" && find . -type f | sort ) > "$before"
  n_before=$(wc -l < "$before")

  if [ "$EXECUTE" != "1" ]; then
    log "DRY-RUN would archive $d ($n_before files)"
    continue
  fi

  log "COPY $d ($n_before files)"
  mkdir -p "$dest"
  rsync -a --info=stats2 "$src/" "$dest/" || die "$d: rsync failed — nothing deleted"

  # ── Defect M2: the page cache defeats a checksum verify ───────────────────
  # A CIFS mount with cache=strict serves the bytes just written, so the
  # comparison reads the copy it is meant to be checking. Drop caches first;
  # a failure to drop is not fatal but is worth saying out loud.
  sync
  if [ -w /proc/sys/vm/drop_caches ]; then
    echo 3 > /proc/sys/vm/drop_caches 2>/dev/null || log "WARN could not drop caches"
  fi

  # NOTE: every diagnostic pipeline below ends in `|| true`. Under
  # `set -euo pipefail` a `diff` that finds differences exits 1, and a `head`
  # that closes early SIGPIPEs its producer — either aborts the script BEFORE
  # the `die` that explains why. The guard still holds and the source still
  # survives; the operator just gets no reason, which is how a correct refusal
  # becomes an unexplained failure.

  # ── Defect M1: the verify must fail CLOSED ────────────────────────────────
  # The predecessor printed `verified: 0 files differ` when rsync itself had
  # FAILED — an error was indistinguishable from agreement — and then deleted
  # the source. An unusable comparison is a refusal, not a pass.
  if ! itemized=$(rsync -a --checksum --dry-run --itemize-changes "$src/" "$dest/" 2>&1); then
    printf '%s\n' "$itemized" | tail -3 >&2 || true
    die "$d: VERIFY FAILED — rsync could not compare the trees. Nothing deleted."
  fi
  pending=$(printf '%s\n' "$itemized" | grep -c '^[<>ch]' || true)
  if [ "$pending" -ne 0 ]; then
    printf '%s\n' "$itemized" | head -5 >&2 || true
    die "$d: VERIFY FAILED — $pending path(s) still differ. Nothing deleted."
  fi

  # ── Defect M4: distinguish a partial copy from a foreign tree ─────────────
  # Zero differences is also what you get when the destination already held an
  # unrelated tree and rsync had nothing to say about the parts it never
  # touched. Compare the inventories.
  after=$(mktemp); trap 'rm -f "$before" "$after"' EXIT
  ( cd "$dest" && find . -type f | sort ) > "$after"
  if ! diff -q "$before" "$after" >/dev/null 2>&1; then
    n_after=$(wc -l < "$after")
    diff "$before" "$after" | head -5 >&2 || true
    die "$d: VERIFY FAILED — source had $n_before files, destination has $n_after. Nothing deleted."
  fi

  log "VERIFIED $d ($n_before files identical)"
  rm -rf -- "${{src:?refusing an empty source path}}"
  if [ "$LEAVE_SYMLINK" = "1" ]; then
    ln -s "$dest" "$src"
    log "SYMLINK $src -> $dest"
  fi
  archived=$((archived+1))
  rm -f "$before" "$after"; trap - EXIT
done

log "archived=$archived skipped=$skipped"
"#,
        src = sh_squote(&a.path),
        dest = sh_squote(&a.destination),
        dirs = dirs,
        max_small_bytes = a.max_small_bytes,
        min_age = a.min_age_days,
        leave_symlink = u8::from(a.leave_symlink),
        small_bytes = SMALL_FILE_BYTES,
    )
}