From 63d91b7a5c86740b373bb20a687406456e79e6dd Mon Sep 17 00:00:00 2001 From: MrFlounder Date: Mon, 16 Mar 2026 13:36:50 -0700 Subject: [PATCH 1/2] feat(env): add `crab env encrypt` command for retry after failed encryption Extract encryption logic from env_snapshot into a standalone env_encrypt function so users can retry encryption if the password verify fails, without re-running the full snapshot agent. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/crabcode | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/crabcode b/src/crabcode index 0a8ef00..c1ecb1b 100755 --- a/src/crabcode +++ b/src/crabcode @@ -11205,11 +11205,15 @@ handle_env_command() { "restore") env_restore "$@" ;; + "encrypt") + env_encrypt "$@" + ;; ""|"help"|"-h"|"--help") echo -e "${BOLD}crab env${NC} - Environment snapshot & restore" echo "" echo -e " ${CYAN}crab env snapshot${NC} Capture environment recipe" echo -e " ${CYAN}crab env snapshot --dry-run${NC} Preview what would be captured" + echo -e " ${CYAN}crab env encrypt ${NC} Encrypt a staging directory" echo -e " ${CYAN}crab env restore --from FILE${NC} Restore from snapshot" echo "" echo "Snapshot launches an AI agent to explore your machine and build" @@ -11391,7 +11395,24 @@ PROMPT_EOF ${EDITOR:-vim} "$staging_dir/recipe.md" fi - # Package and encrypt + env_encrypt "$staging_dir" "$output_path" +} + +# Encrypt a staging directory into a portable .enc bundle +env_encrypt() { + local staging_dir="$1" + local output_path="${2:-}" + + if [ -z "$staging_dir" ] || [ ! -d "$staging_dir" ]; then + error "Staging directory not found: ${staging_dir:-}" + return 1 + fi + + if [ ! -f "$staging_dir/recipe.md" ]; then + error "No recipe.md found in $staging_dir — is this a valid snapshot?" + return 1 + fi + echo "" echo -e "${CYAN}Encrypting snapshot...${NC}" echo -e "${GRAY}Choose a password to protect this snapshot.${NC}" @@ -11400,6 +11421,7 @@ PROMPT_EOF # Determine output path if [ -z "$output_path" ]; then + local timestamp=$(date +%Y%m%d-%H%M%S) output_path="$ENV_SNAPSHOT_DIR/env-snapshot-$timestamp.enc" fi mkdir -p "$(dirname "$output_path")" From 79e32658fe62dbe00e7a7eb7d490d489487e79e7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 2 Oct 2026 17:56:27 -0700 Subject: [PATCH 2/2] fix(env): protect snapshot inputs and existing backups --- README.md | 6 ++ src/crabcode | 34 +++++++++-- tests/unit/test_env_encrypt.bats | 101 +++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_env_encrypt.bats diff --git a/README.md b/README.md index 93169a8..7fea01c 100644 --- a/README.md +++ b/README.md @@ -345,10 +345,16 @@ Capture your entire dev environment and restore it on a new machine. An AI agent ```bash crab env snapshot # Agent explores machine → encrypted bundle crab env snapshot --dry-run # Preview what would be captured +crab env encrypt STAGING_DIR # Retry encryption without rerunning the agent crab env restore --from FILE # Decrypt + agent sets up new machine crab env restore --from URL # Download and restore from URL ``` +If snapshot encryption fails, retry with the staging directory printed by the +command. An optional second argument selects the output file. The output must +be a new file outside the staging directory. Successful encryption removes the +staging directory; a failed attempt preserves it for retry. + The snapshot captures: Homebrew packages, Node/Python/Go versions, shell config, git identity, editor settings, database schemas, Docker config, cloud tools, project inventory, .env files, and API tokens — all encrypted with a password. ## Setup Flow diff --git a/src/crabcode b/src/crabcode index 231dbaa..1bac1d3 100755 --- a/src/crabcode +++ b/src/crabcode @@ -11312,7 +11312,7 @@ handle_env_command() { echo "" echo -e " ${CYAN}crab env snapshot${NC} Capture environment recipe" echo -e " ${CYAN}crab env snapshot --dry-run${NC} Preview what would be captured" - echo -e " ${CYAN}crab env encrypt ${NC} Encrypt a staging directory" + echo -e " ${CYAN}crab env encrypt [output-file]${NC} Retry snapshot encryption" echo -e " ${CYAN}crab env restore --from FILE${NC} Restore from snapshot" echo "" echo "Snapshot launches an AI agent to explore your machine and build" @@ -11499,7 +11499,7 @@ PROMPT_EOF # Encrypt a staging directory into a portable .enc bundle env_encrypt() { - local staging_dir="$1" + local staging_dir="${1:-}" local output_path="${2:-}" if [ -z "$staging_dir" ] || [ ! -d "$staging_dir" ]; then @@ -11512,6 +11512,12 @@ env_encrypt() { return 1 fi + staging_dir=$(cd "$staging_dir" && pwd -P) || return 1 + if [ "$staging_dir" = / ]; then + error "The filesystem root cannot be a staging directory" + return 1 + fi + echo "" echo -e "${CYAN}Encrypting snapshot...${NC}" echo -e "${GRAY}Choose a password to protect this snapshot.${NC}" @@ -11523,11 +11529,28 @@ env_encrypt() { local timestamp=$(date +%Y%m%d-%H%M%S) output_path="$ENV_SNAPSHOT_DIR/env-snapshot-$timestamp.enc" fi - mkdir -p "$(dirname "$output_path")" + mkdir -p "$(dirname "$output_path")" || return 1 + local output_dir + output_dir=$(cd "$(dirname "$output_path")" && pwd -P) || return 1 + output_path="$output_dir/$(basename "$output_path")" + case "$output_path" in + "$staging_dir"/*) + error "Output file must be outside the staging directory" + return 1 + ;; + esac + if [ -e "$output_path" ] || [ -L "$output_path" ]; then + error "Output file already exists: $output_path" + return 1 + fi + + local encrypted_tmp + encrypted_tmp=$(mktemp "$output_dir/.crab-env-XXXXXX") || return 1 # Encrypt with openssl (use pipefail to catch tar or openssl failures) - if ( set -o pipefail; tar czf - -C "$(dirname "$staging_dir")" "$(basename "$staging_dir")" \ - | openssl enc -aes-256-cbc -pbkdf2 -salt -out "$output_path" ); then + if ( set -o pipefail; tar czf - -C "$(dirname "$staging_dir")" -- "$(basename "$staging_dir")" \ + | openssl enc -aes-256-cbc -pbkdf2 -salt -out "$encrypted_tmp" ) \ + && mv "$encrypted_tmp" "$output_path"; then # Clean up staging rm -rf "$staging_dir" @@ -11540,6 +11563,7 @@ env_encrypt() { echo -e " 2. Install crabcode: ${CYAN}curl -fsSL | bash${NC}" echo -e " 3. Run: ${CYAN}crab env restore --from $output_path${NC}" else + rm -f "$encrypted_tmp" error "Encryption failed" echo "Unencrypted snapshot remains at: $staging_dir" return 1 diff --git a/tests/unit/test_env_encrypt.bats b/tests/unit/test_env_encrypt.bats new file mode 100644 index 0000000..0e3bfea --- /dev/null +++ b/tests/unit/test_env_encrypt.bats @@ -0,0 +1,101 @@ +#!/usr/bin/env bats + +load '../test_helper/bats-support/load' +load '../test_helper/bats-assert/load' + +setup() { + TEST_TMPDIR=$(mktemp -d) + source "${BATS_TEST_DIRNAME}/../../src/crabcode" + ENV_SNAPSHOT_DIR="$TEST_TMPDIR/snapshots" + staging_dir="$TEST_TMPDIR/staging" + mkdir -p "$staging_dir" + printf 'Fixture recipe\n' > "$staging_dir/recipe.md" + output_file="$TEST_TMPDIR/snapshot.enc" + + # Use a fixture password so tests exercise real encryption without a prompt. + openssl() { command openssl "$@" -pass pass:crabcode-test-fixture; } +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +@test "env encrypt rejects missing staging directories and recipes" { + run env_encrypt + assert_failure + assert_output --partial 'Staging directory not found' + rm "$staging_dir/recipe.md" + run env_encrypt "$staging_dir" "$output_file" + assert_failure + assert_output --partial 'No recipe.md' + [ ! -e "$output_file" ] +} + +@test "env encrypt round-trips a relative staging path and removes it after success" { + cd "$TEST_TMPDIR" + run handle_env_command encrypt ./staging ./snapshot.enc + assert_success + [ ! -e "$staging_dir" ] + mkdir "$TEST_TMPDIR/restored" + openssl enc -d -aes-256-cbc -pbkdf2 -in "$output_file" \ + | tar xzf - -C "$TEST_TMPDIR/restored" + run cat "$TEST_TMPDIR/restored/staging/recipe.md" + assert_output 'Fixture recipe' +} + +@test "env encrypt supports staging names that begin with a dash" { + mv "$staging_dir" "$TEST_TMPDIR/--fixture" + run env_encrypt "$TEST_TMPDIR/--fixture" "$output_file" + assert_success + run bash -c 'openssl enc -d -aes-256-cbc -pbkdf2 -in "$1" -pass pass:crabcode-test-fixture | tar tzf -' _ "$output_file" + assert_success + assert_output --partial '--fixture/recipe.md' +} + +@test "env encrypt preserves an existing output file" { + printf 'Previous backup\n' > "$output_file" + run env_encrypt "$staging_dir" "$output_file" + assert_failure + assert_output --partial 'already exists' + [ -f "$staging_dir/recipe.md" ] + run cat "$output_file" + assert_output 'Previous backup' +} + +@test "env encrypt rejects output inside staging including symlinked directories" { + ln -s "$staging_dir" "$TEST_TMPDIR/link" + for destination in "$staging_dir/snapshot.enc" "$TEST_TMPDIR/link/snapshot.enc"; do + run env_encrypt "$staging_dir" "$destination" + assert_failure + assert_output --partial 'outside the staging directory' + [ -f "$staging_dir/recipe.md" ] + [ ! -e "$destination" ] + done +} + +@test "env encrypt preserves staging and removes partial output on encryption failure" { + openssl() { cat >/dev/null; return 1; } + run env_encrypt "$staging_dir" "$output_file" + assert_failure + [ -f "$staging_dir/recipe.md" ] + [ ! -e "$output_file" ] + run find "$TEST_TMPDIR" -name '.crab-env-*' + assert_output '' +} + +@test "env encrypt preserves staging when tar fails" { + tar() { return 1; } + run env_encrypt "$staging_dir" "$output_file" + assert_failure + [ -f "$staging_dir/recipe.md" ] + [ ! -e "$output_file" ] +} + +@test "env encrypt uses the default snapshot directory when output is omitted" { + run handle_env_command encrypt "$staging_dir" + assert_success + [ ! -e "$staging_dir" ] + run find "$ENV_SNAPSHOT_DIR" -name 'env-snapshot-*.enc' + assert_success + [ -n "$output" ] +}