From 0049584d9b90ae70d3ace7d96ebfe6c4367c002f Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Sun, 7 Jun 2026 08:49:02 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20manifest-driven=20pull=20=E2=80=94=20ne?= =?UTF-8?q?sted=20restore,=20restore-time=20rail,=20empty=20no-op=20(EGB-6?= =?UTF-8?q?77=20stage=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pull with .secrets.json restores exactly the declared entries (nested paths get mkdir -p); stray store blobs are not restored - dotenv rail re-runs at restore time: unsafe entries warn+skip (pull never dies on one bad entry), missing blobs warn with a directed hint - empty manifest = warn no-op instead of a confusing 'not found' death - manifest-less projects keep the legacy glob pull verbatim --- secrets | 48 +++++++++++++++++++++++++++ test/manifest.bats | 83 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/secrets b/secrets index e02e636..1e75d98 100755 --- a/secrets +++ b/secrets @@ -1414,6 +1414,54 @@ cmd_pull() { git -C "$SECRETS_DIR" pull >/dev/null 2>&1 fi + # ── Manifest-driven pull (EGB-677 stage 1) ── + # With a .secrets.json present, the manifest decides what restores and + # where (nested entries get their directories created). The dotenv rail + # runs again at restore time — warn+skip on pull, never die, so one bad + # entry can't block the rest of the restore. + local manifest="$PWD/$SECRETS_JSON_NAME" + if [ -e "$manifest" ]; then + _check_manifest_file "$manifest" + local declared n_external + declared=$(jq -r '.dotenv // [] | .[]' "$manifest") + n_external=$(jq -r '.external // [] | length' "$manifest") + + if [ -z "$declared" ] && [ "$n_external" -eq 0 ]; then + echo "WARNING: $SECRETS_JSON_NAME declares nothing to pull (empty manifest). Run 'secrets push' on a machine that has the files." >&2 + ensure_store_protections + return 0 + fi + if [ -n "$declared" ] && [ ! -d "$SECRETS_DIR/$project" ]; then + die "Project '$project' not found. Run: secrets list" + fi + + local count=0 rel + while IFS= read -r rel; do + [ -n "$rel" ] || continue + if ! _validate_dotenv_rel_path "$rel" 2>/dev/null; then + echo "WARNING: skipping unsafe dotenv path from $SECRETS_JSON_NAME: $rel" >&2 + continue + fi + local blob="$SECRETS_DIR/$project/${rel}.age" + if [ ! -f "$blob" ]; then + echo "WARNING: '$rel' is declared in $SECRETS_JSON_NAME but has no encrypted data in the store yet. Run 'secrets push' on a machine that has it. Skipping." >&2 + continue + fi + case "$rel" in */*) mkdir -p "$target_dir/$(dirname "$rel")" ;; esac + age -d -i "$KEY_FILE" -o "$target_dir/$rel" "$blob" + if [ ! -s "$target_dir/$rel" ]; then + echo "WARNING: Decrypted file '$rel' is empty (possibly truncated .age blob)" + fi + count=$((count + 1)) + done <<< "$declared" + + info "Decrypted $count file(s) into $target_dir" + pull_external_files "$PWD" "$project" + ensure_store_protections + return 0 + fi + + # ── Legacy glob pull (manifest-less projects; unchanged) ── # Check project exists if [ ! -d "$SECRETS_DIR/$project" ]; then die "Project '$project' not found. Run: secrets list" diff --git a/test/manifest.bats b/test/manifest.bats index d9e97ae..f23a5aa 100644 --- a/test/manifest.bats +++ b/test/manifest.bats @@ -379,3 +379,86 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' > [ "$status" -eq 0 ] [[ "$output" == *"superseded"* ]] || false } + +# ─── D: pull from manifest — nested restore, restore-time rail ───────── + +@test "pull restores manifest-declared nested file (mkdir -p)" { + init_with_remote + create_project_dir nestpull + mkdir -p packages/web + echo "K=v" > packages/web/.env.development + "$SECRETS_BIN" add packages/web/.env.development >/dev/null + "$SECRETS_BIN" push >/dev/null 2>&1 + rm -rf packages + run "$SECRETS_BIN" pull + [ "$status" -eq 0 ] + [ -f packages/web/.env.development ] + [ "$(cat packages/web/.env.development)" = "K=v" ] +} + +@test "pull with manifest restores only declared entries" { + init_with_remote + create_project_dir onlydecl + "$SECRETS_BIN" push >/dev/null 2>&1 + # plant an undeclared stray blob in the store + local pubkey; pubkey=$(age-keygen -y "$SECRETS_DIR/key.txt") + echo "S=1" | age -r "$pubkey" -o "$SECRETS_DIR/onlydecl/.env.stray.age" + rm -f .env .env.staging + run "$SECRETS_BIN" pull + [ "$status" -eq 0 ] + [ -f .env ] + [ ! -f .env.stray ] +} + +@test "pull warns and skips an unsafe manifest entry, restores the rest" { + init_with_remote + create_project_dir unsafepull + "$SECRETS_BIN" add .env >/dev/null + "$SECRETS_BIN" push >/dev/null 2>&1 + printf '{"version":2,"dotenv":[".env","../escape/.env"]}\n' > .secrets.json + rm -f .env + run "$SECRETS_BIN" pull + [ "$status" -eq 0 ] + [[ "$output" == *"skipping unsafe"* ]] || false + [ -f .env ] + [ ! -f "$WORK_DIR/escape/.env" ] +} + +@test "pull on an empty manifest is a warn no-op" { + init_with_remote + mkdir -p "$WORK_DIR/emptypull" + cd "$WORK_DIR/emptypull" + printf '{"version":2,"dotenv":[]}\n' > .secrets.json + run "$SECRETS_BIN" pull emptypull + [ "$status" -eq 0 ] + [[ "$output" == *"declares nothing"* ]] || false +} + +@test "pull warns when a declared entry has no blob in the store" { + init_with_remote + create_project_dir nopullblob + "$SECRETS_BIN" push >/dev/null 2>&1 + jq '.dotenv += [".env.missing"]' .secrets.json > .secrets.json.tmp && mv .secrets.json.tmp .secrets.json + run "$SECRETS_BIN" pull + [ "$status" -eq 0 ] + [[ "$output" == *".env.missing"* ]] || false + [[ "$output" == *"no encrypted data"* ]] || false +} + +@test "machine-2 flow: committed manifest + pull restores everything" { + init_with_remote + create_project_dir machine1 + mkdir -p packages/api + echo "API=1" > packages/api/.dev.vars + "$SECRETS_BIN" add packages/api/.dev.vars >/dev/null + "$SECRETS_BIN" push m2proj >/dev/null 2>&1 + # simulate machine 2: fresh dir, only the committed manifest present + mkdir -p "$WORK_DIR/machine2" + cp .secrets.json "$WORK_DIR/machine2/" + cd "$WORK_DIR/machine2" + run "$SECRETS_BIN" pull m2proj + [ "$status" -eq 0 ] + [ -f .env ] + [ -f packages/api/.dev.vars ] + [ "$(cat packages/api/.dev.vars)" = "API=1" ] +}