use crate::core::shell_escape::sh_squote;
use crate::core::types::{NasArchive, SMALL_FILE_BYTES};
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,
)
}