From 1a30322421fce4e4ef7128f9d3e5fd84312e62cd Mon Sep 17 00:00:00 2001 From: Richard Freeman Date: Sat, 18 Jul 2026 07:28:34 +0000 Subject: [PATCH 1/8] feature: Stage 3 merge tools use /tmp views of ancestor and marker (issue #65) Pass disposable copies of the 3-way ancestor and Portage ._cfg* marker to interactive merge tools so accidental saves on non-live panes cannot corrupt permanent backups or markers. Live file and *.merge output paths stay real. Also extends Tier D mocks/tests (hostile input overwrite, temp path checks) and clarifies tool_intro for 3-way tools. --- cfg-update | 99 ++++++++++++++++++++++++++++++++++++++- docs/ARCHITECTURE.md | 2 + test/run-tests.sh | 109 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 207 insertions(+), 3 deletions(-) diff --git a/cfg-update b/cfg-update index dddb870..96a812b 100755 --- a/cfg-update +++ b/cfg-update @@ -167,6 +167,8 @@ $Term::ANSIColor::AUTORESET = 1; my $path_merged; # /path/file.merge <-- merge result (*.merge) my $path_temp_old; # /path/._temp_old-cfg_file <-- temp copy of live config during merge my $path_temp_new; # /path/._temp_new-cfg_file <-- temp copy of marker during merge + my $path_view_ancestor; # /tmp/cfg-update-$$-ancestor-* <-- Stage 3 merge-tool view (disposable) + my $path_view_new; # /tmp/cfg-update-$$-new-* <-- Stage 3 merge-tool view (disposable) my $is_executable; my $ancestor; my $log_pkg = ""; @@ -955,6 +957,85 @@ sub make_temp_backups{ #ARGS# ("pretend|execute") if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."\n"; } } +# Disposable /tmp copies of ancestor + marker for Stage 3 merge tools so accidental +# saves on middle/right panes cannot corrupt $path_backup_new or $path_new (issue #65). +# Orthogonal to make_temp_backups (which stages permanent backup promotion). +sub make_merge_view_temps { + if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; } + $path_view_ancestor = ""; + $path_view_new = ""; + my $tmpdir = $ENV{TMPDIR}; + if (!defined $tmpdir || $tmpdir eq "") { $tmpdir = "/tmp"; } + $tmpdir =~ s|/+$||; + if (!-d $tmpdir) { + if ($opt_d >= 1) { print "$tab"." mkdir -p \"$tmpdir\"\n"; } + `mkdir -p "$tmpdir" $debug`; + } + my $safe = $cfg_basename; + $safe =~ s/[^A-Za-z0-9._-]/_/g; + if ($safe eq "") { $safe = "file"; } + if (-e $path_backup_new) { + $path_view_ancestor = "$tmpdir/cfg-update-$$-ancestor-$safe"; + if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." cp -pP \"$path_backup_new\" \"$path_view_ancestor\"\n"; } + `cp -pP "$path_backup_new" "$path_view_ancestor" $debug`; + if (!-e $path_view_ancestor) { + if ($opt_d >= 1) { print "$tab"." failed to create $path_view_ancestor; using real ancestor\n"; } + $path_view_ancestor = ""; + } + } + if (-e $path_new) { + $path_view_new = "$tmpdir/cfg-update-$$-new-$safe"; + if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." cp -pP \"$path_new\" \"$path_view_new\"\n"; } + `cp -pP "$path_new" "$path_view_new" $debug`; + if (!-e $path_view_new) { + if ($opt_d >= 1) { print "$tab"." failed to create $path_view_new; using real marker\n"; } + $path_view_new = ""; + } + } + if ($opt_d >= 1) { + print "$tab"." path_view_ancestor = $path_view_ancestor\n"; + print "$tab"." path_view_new = $path_view_new\n"; + $tab =~ s/ //; print "$tab"."\n"; + } +} + +sub cleanup_merge_view_temps { + if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; } + if (defined $path_view_ancestor && $path_view_ancestor ne "" && -e $path_view_ancestor) { + if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$path_view_ancestor\"\n"; } + `rm -f "$path_view_ancestor" $debug`; + } + if (defined $path_view_new && $path_view_new ne "" && -e $path_view_new) { + if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$path_view_new\"\n"; } + `rm -f "$path_view_new" $debug`; + } + $path_view_ancestor = ""; + $path_view_new = ""; + if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."\n"; } +} + +# Launch merge tool for Stage 3 with disposable /tmp views of ancestor + marker. +# Restores real $path_backup_new / $path_new before return so complete/cancel paths stay correct. +sub launch_tool_with_merge_view_temps { #ARGS# ("pretend|execute","mergetool") + if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; } + my $mode = $_[0]; + my $tool = $_[1]; + my $real_backup_new = $path_backup_new; + my $real_new = $path_new; + if ($mode =~ /execute/) { + &make_merge_view_temps; + if ($path_view_ancestor ne "") { $path_backup_new = $path_view_ancestor; } + if ($path_view_new ne "") { $path_new = $path_view_new; } + } + &launch_tool($mode, $tool); + $path_backup_new = $real_backup_new; + $path_new = $real_new; + if ($mode =~ /execute/) { + &cleanup_merge_view_temps; + } + if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."\n"; } +} + sub update_stage1{ #ARGS# ("pretend|execute") if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; } if ($enable_stage1 !~ /^yes$|^true$|^on$/i) { @@ -1101,7 +1182,9 @@ sub update_stage3{ #ARGS# ("pretend|execute") if ($key =~ /2/) { &update_keep_complete($_[0]); $key="s"; } if ($key =~ /v|y/) { &tool_intro($merge_tool_name); - &launch_tool($_[0],$merge_tool); + # Stage 3 only: pass /tmp copies of ancestor + marker so accidental + # saves cannot corrupt permanent backups or Portage markers (issue #65). + &launch_tool_with_merge_view_temps($_[0],$merge_tool); if (-e $path_merged) { if ($tool_saves_mergefile_when_aborted =~ "no") { print "$tab"." Interactive merging completed... (or aborted)\n"; @@ -1810,10 +1893,14 @@ sub tool_intro{ #ARGS# ("mergetoolname") print "$tab"." In $_[0] you select the lines that you want to keep by simply\n"; print "$tab"." clicking on the colored lines. They will appear in the merge-pane.\n"; print "$tab"." When done, click the M-button to save the result, then exit $_[0]!\n"; + print "$tab"." In 3-way mode the ancestor and new-file panes are temporary copies;\n"; + print "$tab"." accidental edits there are discarded. Save the merge output only.\n"; print "$tab"." When you exit $_[0], $progname will finish the update.\n"; } elsif ($_[0] =~ /^kdiff3$/) { print "$tab"." In $_[0] you select the lines that you want to keep.\n"; print "$tab"." When done, click the save button and exit $_[0]!\n"; + print "$tab"." In 3-way mode the ancestor and new-file panes are temporary copies;\n"; + print "$tab"." accidental edits there are discarded. Save the merge output only.\n"; print "$tab"." When you exit $_[0], $progname will finish the update...\n"; } elsif ($_[0] =~ /^kompare$/) { print "$tab"." In $_[0] you select the lines that you want to keep.\n"; @@ -1836,10 +1923,20 @@ sub tool_intro{ #ARGS# ("mergetoolname") print "$tab"." In $_[0] you select the lines that you want to keep.\n"; print "$tab"." When done, save the merged result over the current configfile by\n"; print "$tab"." right-clicking on the left pane and chosing \"Save\"!\n"; + print "$tab"." In 3-way mode the middle (ancestor) and right (new) panes are\n"; + print "$tab"." temporary copies; accidental edits there are discarded.\n"; print "$tab"." When you exit $_[0], $progname will finish the update...\n"; } elsif ($_[0] =~ /^tkdiff$/) { print "$tab"." In $_[0] you select the lines that you want to keep.\n"; print "$tab"." When done, save the merged result with the \"Save & Exit\" button!\n"; + print "$tab"." In 3-way mode the ancestor and new-file panes are temporary copies;\n"; + print "$tab"." accidental edits there are discarded. Save the merge output only.\n"; + print "$tab"." When you exit $_[0], $progname will finish the update...\n"; + } elsif ($_[0] =~ /^imediff$/) { + print "$tab"." In $_[0] you select the lines that you want to keep.\n"; + print "$tab"." When done, the merged result is written to the *.merge output file.\n"; + print "$tab"." In 3-way mode the ancestor and new-file inputs are temporary copies;\n"; + print "$tab"." accidental edits there are discarded.\n"; print "$tab"." When you exit $_[0], $progname will finish the update...\n"; } else { print "$tab"." In $_[0] you select the lines that you want to keep.\n"; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ea62aa4..287f3e2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -176,6 +176,8 @@ No conflict markers → apply merged result. Conflict → defer to stage 3. Launches the configured merge tool with ancestor, live, and new files. Requires a tool with 3-way support (meld, kdiff3, xxdiff, tkdiff, imediff). +For Stage 3 only, the ancestor (`$path_backup_new`) and Portage marker (`$path_new`) are copied to disposable files under `/tmp` (or `$TMPDIR`) before the tool runs, so accidental saves on non-live panes cannot corrupt permanent backups or markers. The live file and `*.merge` output paths remain real. See issue #65. + ### Stage 4 — Manual 2-way merge (`update_stage4`) Merges live file and `._cfg*` update when no backup exists. Works with all supported tools. diff --git a/test/run-tests.sh b/test/run-tests.sh index 3c5f412..80cfebf 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -267,8 +267,11 @@ setup_multi_config_protect_sandbox() { run_cfg_update() { local extra_args=("$@") + # Isolate disposable Stage 3 merge-view temps under the sandbox (issue #65). + mkdir -p "$SANDBOX/tmp" CFG_UPDATE_CONF="$SANDBOX/etc/cfg-update.conf" \ PATH="$SANDBOX/bin:$PATH" \ + TMPDIR="$SANDBOX/tmp" \ perl "$CFG_UPDATE" --ebuild --testsandbox "${extra_args[@]}" } @@ -288,15 +291,31 @@ echo "$*" >>"$log" outfile="" ancestor="" threeway="no" +files=() while [[ $# -gt 0 ]]; do case "$1" in -o) outfile="$2"; shift 2 ;; -b) ancestor="$2"; threeway="yes"; shift 2 ;; -m) shift ;; - *) shift ;; + *) files+=("$1"); shift ;; esac done echo "THREE_WAY=$threeway" >>"$log" +if [[ -n "$ancestor" ]]; then + echo "ANCESTOR=$ancestor" >>"$log" +fi +if [[ ${#files[@]} -ge 1 ]]; then + echo "LIVE=${files[0]}" >>"$log" +fi +if [[ ${#files[@]} -ge 2 ]]; then + echo "NEW=${files[1]}" >>"$log" +fi +# Hostile mode: overwrite tool inputs to simulate accidental pane saves (issue #65). +if [[ "${CFG_UPDATE_MOCK_TRASH_INPUTS:-}" == "1" ]]; then + [[ -n "$ancestor" && -f "$ancestor" ]] && echo "TRASHED-ANCESTOR" >"$ancestor" + [[ ${#files[@]} -ge 2 && -f "${files[1]}" ]] && echo "TRASHED-NEW" >"${files[1]}" + echo "TRASHED_INPUTS=yes" >>"$log" +fi if [[ -n "$outfile" && -f "${CFG_UPDATE_TEST_SANDBOX}/golden.merge" ]]; then cp "${CFG_UPDATE_TEST_SANDBOX}/golden.merge" "$outfile" fi @@ -368,9 +387,20 @@ if [[ "$use_a" == "yes" ]]; then echo "USE_A=yes" >>"$log" fi case "${#files[@]}" in - 3) echo "THREE_WAY=yes" >>"$log" ;; + 3) + echo "THREE_WAY=yes" >>"$log" + echo "LIVE=${files[0]}" >>"$log" + echo "ANCESTOR=${files[1]}" >>"$log" + echo "NEW=${files[2]}" >>"$log" + ;; 2) echo "TWO_WAY=yes" >>"$log" ;; esac +# Hostile mode: overwrite tool inputs to simulate accidental pane saves (issue #65). +if [[ "${CFG_UPDATE_MOCK_TRASH_INPUTS:-}" == "1" && ${#files[@]} -eq 3 ]]; then + [[ -f "${files[1]}" ]] && echo "TRASHED-ANCESTOR" >"${files[1]}" + [[ -f "${files[2]}" ]] && echo "TRASHED-NEW" >"${files[2]}" + echo "TRASHED_INPUTS=yes" >>"$log" +fi if [[ -n "$outfile" && -f "${CFG_UPDATE_TEST_SANDBOX}/golden.merge" ]]; then cp "${CFG_UPDATE_TEST_SANDBOX}/golden.merge" "$outfile" fi @@ -780,6 +810,77 @@ tier_d_execute_manual() { "$SANDBOX/etc/test/test_auto_3way_conflict" \ "$FIXTURES/stage2-3way-merge-conflict/expected/test_auto_3way_conflict.after_replace" + # Stage 3: ancestor/new args are disposable /tmp views (issue #65), not real paths + local real_ancestor real_marker + real_ancestor="$SANDBOX/var/lib/cfg-update/backups${SANDBOX}/etc/test/._new-cfg_test_auto_3way_conflict" + real_marker="$SANDBOX/etc/test/._cfg0000_test_auto_3way_conflict" + assert_file_contains "stage3 kdiff3 ancestor arg is merge-view temp" \ + "$SANDBOX/mock-kdiff3.log" "ANCESTOR=${SANDBOX}/tmp/cfg-update-" + assert_file_contains "stage3 kdiff3 new arg is merge-view temp" \ + "$SANDBOX/mock-kdiff3.log" "NEW=${SANDBOX}/tmp/cfg-update-" + if grep -q "ANCESTOR=${real_ancestor}" "$SANDBOX/mock-kdiff3.log" 2>/dev/null; then + fail "stage3 kdiff3 must not pass real ancestor path" + else + pass "stage3 kdiff3 did not pass real ancestor path" + fi + if grep -q "NEW=${real_marker}" "$SANDBOX/mock-kdiff3.log" 2>/dev/null; then + fail "stage3 kdiff3 must not pass real marker path" + else + pass "stage3 kdiff3 did not pass real marker path" + fi + local leftover + leftover="$(find "$SANDBOX/tmp" -name 'cfg-update-*' 2>/dev/null | wc -l)" + if [[ "$leftover" -eq 0 ]]; then + pass "stage3 cleaned up merge-view temps" + else + fail "stage3 left $leftover merge-view temp(s) under TMPDIR" + fi + + # Stage 3: hostile mock trashes tool inputs; real ancestor/marker must survive cancel + setup_sandbox stage2-3way-merge-conflict stage3_only + install_mock_kdiff3 # no golden → no $path_merged → cancel/finish prompt + sed -i "s|^MERGE_TOOL = .*|MERGE_TOOL = $SANDBOX/bin/kdiff3|" "$SANDBOX/etc/cfg-update.conf" + real_ancestor="$SANDBOX/var/lib/cfg-update/backups${SANDBOX}/etc/test/._new-cfg_test_auto_3way_conflict" + real_marker="$SANDBOX/etc/test/._cfg0000_test_auto_3way_conflict" + local ancestor_before marker_before + ancestor_before="$(md5sum "$real_ancestor" | awk '{print $1}')" + marker_before="$(md5sum "$real_marker" | awk '{print $1}')" + output="$(CFG_UPDATE_MOCK_TRASH_INPUTS=1 run_cfg_update_stdin $'y\ns\n' -u 2>&1)" || true + assert_file_contains "stage3 hostile mock trashed inputs" \ + "$SANDBOX/mock-kdiff3.log" "TRASHED_INPUTS=yes" + local ancestor_after marker_after + ancestor_after="$(md5sum "$real_ancestor" | awk '{print $1}')" + marker_after="$(md5sum "$real_marker" | awk '{print $1}')" + if [[ "$ancestor_before" == "$ancestor_after" ]]; then + pass "stage3 hostile cancel left real ancestor intact" + else + fail "stage3 hostile cancel corrupted real ancestor" + fi + if [[ "$marker_before" == "$marker_after" ]]; then + pass "stage3 hostile cancel left real marker intact" + else + fail "stage3 hostile cancel corrupted real marker" + fi + assert_file_exists "stage3 hostile cancel kept cfg marker" "$real_marker" + + # Stage 3: hostile mock trashes inputs; successful merge still uses real paths for complete + setup_sandbox stage2-3way-merge-conflict stage3_only + install_mock_kdiff3 \ + "$FIXTURES/stage2-3way-merge-conflict/expected/test_auto_3way_conflict.after_replace" + sed -i "s|^MERGE_TOOL = .*|MERGE_TOOL = $SANDBOX/bin/kdiff3|" "$SANDBOX/etc/cfg-update.conf" + # Sandbox --ebuild sets tool_saves_mergefile_when_aborted=no, so confirm with [1]. + output="$(CFG_UPDATE_MOCK_TRASH_INPUTS=1 run_cfg_update_stdin $'y\n1\n' -u 2>&1)" || true + assert_stage_output "stage3 hostile complete" 3 "$output" + assert_file_equals "stage3 hostile complete matches golden" \ + "$SANDBOX/etc/test/test_auto_3way_conflict" \ + "$FIXTURES/stage2-3way-merge-conflict/expected/test_auto_3way_conflict.after_replace" + assert_missing "stage3 hostile complete removed cfg marker" \ + "$SANDBOX/etc/test/._cfg0000_test_auto_3way_conflict" + # After complete, ancestor is replaced by promoted path_temp_new (pre-merge marker), + # not by the trashed view copy. The important check: trash never touched real files mid-run. + assert_file_contains "stage3 hostile complete used merge-view temps" \ + "$SANDBOX/mock-kdiff3.log" "ANCESTOR=${SANDBOX}/tmp/cfg-update-" + # Stage 3: mock imediff must receive 3-way (-a -o live ancestor new) setup_sandbox stage2-3way-merge-conflict stage3_only install_mock_imediff \ @@ -794,6 +895,10 @@ tier_d_execute_manual() { assert_file_equals "stage3 mock imediff merge matches golden" \ "$SANDBOX/etc/test/test_auto_3way_conflict" \ "$FIXTURES/stage2-3way-merge-conflict/expected/test_auto_3way_conflict.after_replace" + assert_file_contains "stage3 imediff ancestor arg is merge-view temp" \ + "$SANDBOX/mock-imediff.log" "ANCESTOR=${SANDBOX}/tmp/cfg-update-" + assert_file_contains "stage3 imediff new arg is merge-view temp" \ + "$SANDBOX/mock-imediff.log" "NEW=${SANDBOX}/tmp/cfg-update-" # Stage 4: mock sdiff must run 2-way merge (no -b ancestor) setup_sandbox stage4-manual-2way stage4_only From 78db55ab0dfc2f698e4d933624dd1f6ab7d7fa84 Mon Sep 17 00:00:00 2001 From: Richard Freeman Date: Sat, 18 Jul 2026 10:45:17 +0000 Subject: [PATCH 2/8] refactor: pass Stage 3 merge-view temps via parameters and returns Drop file-scope $path_view_* globals. make_merge_view_temps takes basename and real paths and returns the two view paths; cleanup takes those paths; launch_tool_with_merge_view_temps keeps them as my locals and only rebinds existing $path_backup_new/$path_new around launch_tool. --- cfg-update | 80 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/cfg-update b/cfg-update index 96a812b..55f2e30 100755 --- a/cfg-update +++ b/cfg-update @@ -167,8 +167,6 @@ $Term::ANSIColor::AUTORESET = 1; my $path_merged; # /path/file.merge <-- merge result (*.merge) my $path_temp_old; # /path/._temp_old-cfg_file <-- temp copy of live config during merge my $path_temp_new; # /path/._temp_new-cfg_file <-- temp copy of marker during merge - my $path_view_ancestor; # /tmp/cfg-update-$$-ancestor-* <-- Stage 3 merge-tool view (disposable) - my $path_view_new; # /tmp/cfg-update-$$-new-* <-- Stage 3 merge-tool view (disposable) my $is_executable; my $ancestor; my $log_pkg = ""; @@ -960,10 +958,12 @@ sub make_temp_backups{ #ARGS# ("pretend|execute") # Disposable /tmp copies of ancestor + marker for Stage 3 merge tools so accidental # saves on middle/right panes cannot corrupt $path_backup_new or $path_new (issue #65). # Orthogonal to make_temp_backups (which stages permanent backup promotion). -sub make_merge_view_temps { +# Takes basename + real paths; returns ($view_ancestor, $view_new) path strings ("" if unused). +sub make_merge_view_temps { #ARGS# ($basename, $path_backup_new, $path_new) if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; } - $path_view_ancestor = ""; - $path_view_new = ""; + my ($basename, $src_backup_new, $src_new) = @_; + my $view_ancestor = ""; + my $view_new = ""; my $tmpdir = $ENV{TMPDIR}; if (!defined $tmpdir || $tmpdir eq "") { $tmpdir = "/tmp"; } $tmpdir =~ s|/+$||; @@ -971,67 +971,69 @@ sub make_merge_view_temps { if ($opt_d >= 1) { print "$tab"." mkdir -p \"$tmpdir\"\n"; } `mkdir -p "$tmpdir" $debug`; } - my $safe = $cfg_basename; + my $safe = $basename; + $safe = "" unless defined $safe; $safe =~ s/[^A-Za-z0-9._-]/_/g; if ($safe eq "") { $safe = "file"; } - if (-e $path_backup_new) { - $path_view_ancestor = "$tmpdir/cfg-update-$$-ancestor-$safe"; - if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." cp -pP \"$path_backup_new\" \"$path_view_ancestor\"\n"; } - `cp -pP "$path_backup_new" "$path_view_ancestor" $debug`; - if (!-e $path_view_ancestor) { - if ($opt_d >= 1) { print "$tab"." failed to create $path_view_ancestor; using real ancestor\n"; } - $path_view_ancestor = ""; + if (defined $src_backup_new && -e $src_backup_new) { + $view_ancestor = "$tmpdir/cfg-update-$$-ancestor-$safe"; + if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." cp -pP \"$src_backup_new\" \"$view_ancestor\"\n"; } + `cp -pP "$src_backup_new" "$view_ancestor" $debug`; + if (!-e $view_ancestor) { + if ($opt_d >= 1) { print "$tab"." failed to create $view_ancestor; using real ancestor\n"; } + $view_ancestor = ""; } } - if (-e $path_new) { - $path_view_new = "$tmpdir/cfg-update-$$-new-$safe"; - if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." cp -pP \"$path_new\" \"$path_view_new\"\n"; } - `cp -pP "$path_new" "$path_view_new" $debug`; - if (!-e $path_view_new) { - if ($opt_d >= 1) { print "$tab"." failed to create $path_view_new; using real marker\n"; } - $path_view_new = ""; + if (defined $src_new && -e $src_new) { + $view_new = "$tmpdir/cfg-update-$$-new-$safe"; + if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." cp -pP \"$src_new\" \"$view_new\"\n"; } + `cp -pP "$src_new" "$view_new" $debug`; + if (!-e $view_new) { + if ($opt_d >= 1) { print "$tab"." failed to create $view_new; using real marker\n"; } + $view_new = ""; } } if ($opt_d >= 1) { - print "$tab"." path_view_ancestor = $path_view_ancestor\n"; - print "$tab"." path_view_new = $path_view_new\n"; + print "$tab"." path_view_ancestor = $view_ancestor\n"; + print "$tab"." path_view_new = $view_new\n"; $tab =~ s/ //; print "$tab"."\n"; } + return ($view_ancestor, $view_new); } -sub cleanup_merge_view_temps { +sub cleanup_merge_view_temps { #ARGS# ($view_ancestor, $view_new) if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; } - if (defined $path_view_ancestor && $path_view_ancestor ne "" && -e $path_view_ancestor) { - if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$path_view_ancestor\"\n"; } - `rm -f "$path_view_ancestor" $debug`; + my ($view_ancestor, $view_new) = @_; + if (defined $view_ancestor && $view_ancestor ne "" && -e $view_ancestor) { + if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$view_ancestor\"\n"; } + `rm -f "$view_ancestor" $debug`; } - if (defined $path_view_new && $path_view_new ne "" && -e $path_view_new) { - if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$path_view_new\"\n"; } - `rm -f "$path_view_new" $debug`; + if (defined $view_new && $view_new ne "" && -e $view_new) { + if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$view_new\"\n"; } + `rm -f "$view_new" $debug`; } - $path_view_ancestor = ""; - $path_view_new = ""; if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."\n"; } } # Launch merge tool for Stage 3 with disposable /tmp views of ancestor + marker. -# Restores real $path_backup_new / $path_new before return so complete/cancel paths stay correct. +# Temporarily rebinds existing $path_backup_new / $path_new for launch_tool only, then +# restores them so complete/cancel paths still use the real marker and ancestor. sub launch_tool_with_merge_view_temps { #ARGS# ("pretend|execute","mergetool") if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; } - my $mode = $_[0]; - my $tool = $_[1]; + my ($mode, $tool) = @_; my $real_backup_new = $path_backup_new; my $real_new = $path_new; + my ($view_ancestor, $view_new) = ("", ""); if ($mode =~ /execute/) { - &make_merge_view_temps; - if ($path_view_ancestor ne "") { $path_backup_new = $path_view_ancestor; } - if ($path_view_new ne "") { $path_new = $path_view_new; } + ($view_ancestor, $view_new) = make_merge_view_temps($cfg_basename, $path_backup_new, $path_new); + if ($view_ancestor ne "") { $path_backup_new = $view_ancestor; } + if ($view_new ne "") { $path_new = $view_new; } } - &launch_tool($mode, $tool); + launch_tool($mode, $tool); $path_backup_new = $real_backup_new; $path_new = $real_new; if ($mode =~ /execute/) { - &cleanup_merge_view_temps; + cleanup_merge_view_temps($view_ancestor, $view_new); } if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."\n"; } } From dcd39a56689e62b27bd8cf57fa21bc4d5e23962b Mon Sep 17 00:00:00 2001 From: Richard Freeman Date: Sat, 18 Jul 2026 10:52:54 +0000 Subject: [PATCH 3/8] refactor: single-file merge-view temp make/cleanup helpers Replace the Stage-3-only pair API with make_merge_view_temp / cleanup_merge_view_temp operating on one path. Stage 3 calls each twice. Keeps a thin cleanup_merge_view_temps(@paths) convenience for 0-N temps. Prepares for Stage 4 marker-only views (issue #68). --- cfg-update | 89 ++++++++++++++++++++++---------------------- docs/ARCHITECTURE.md | 2 +- 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/cfg-update b/cfg-update index 55f2e30..a0b0bea 100755 --- a/cfg-update +++ b/cfg-update @@ -955,15 +955,18 @@ sub make_temp_backups{ #ARGS# ("pretend|execute") if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."\n"; } } -# Disposable /tmp copies of ancestor + marker for Stage 3 merge tools so accidental -# saves on middle/right panes cannot corrupt $path_backup_new or $path_new (issue #65). +# Disposable /tmp view of one non-live merge input so accidental tool saves cannot +# corrupt permanent backups or Portage markers (issue #65; Stage 4: issue #68). # Orthogonal to make_temp_backups (which stages permanent backup promotion). -# Takes basename + real paths; returns ($view_ancestor, $view_new) path strings ("" if unused). -sub make_merge_view_temps { #ARGS# ($basename, $path_backup_new, $path_new) - if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; } - my ($basename, $src_backup_new, $src_new) = @_; - my $view_ancestor = ""; - my $view_new = ""; +# $role is a filename tag only (e.g. "ancestor", "new"). Returns view path or "". +sub make_merge_view_temp { #ARGS# ($src_path, $role, $basename) + if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; } + my ($src, $role, $basename) = @_; + my $view = ""; + if (!defined $src || $src eq "" || !-e $src) { + if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."\n"; } + return $view; + } my $tmpdir = $ENV{TMPDIR}; if (!defined $tmpdir || $tmpdir eq "") { $tmpdir = "/tmp"; } $tmpdir =~ s|/+$||; @@ -971,48 +974,42 @@ sub make_merge_view_temps { #ARGS# ($basename, $path_backup_new, $path_new) if ($opt_d >= 1) { print "$tab"." mkdir -p \"$tmpdir\"\n"; } `mkdir -p "$tmpdir" $debug`; } - my $safe = $basename; - $safe = "" unless defined $safe; + my $safe_role = defined $role ? $role : "view"; + $safe_role =~ s/[^A-Za-z0-9._-]/_/g; + if ($safe_role eq "") { $safe_role = "view"; } + my $safe = defined $basename ? $basename : ""; $safe =~ s/[^A-Za-z0-9._-]/_/g; if ($safe eq "") { $safe = "file"; } - if (defined $src_backup_new && -e $src_backup_new) { - $view_ancestor = "$tmpdir/cfg-update-$$-ancestor-$safe"; - if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." cp -pP \"$src_backup_new\" \"$view_ancestor\"\n"; } - `cp -pP "$src_backup_new" "$view_ancestor" $debug`; - if (!-e $view_ancestor) { - if ($opt_d >= 1) { print "$tab"." failed to create $view_ancestor; using real ancestor\n"; } - $view_ancestor = ""; - } - } - if (defined $src_new && -e $src_new) { - $view_new = "$tmpdir/cfg-update-$$-new-$safe"; - if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." cp -pP \"$src_new\" \"$view_new\"\n"; } - `cp -pP "$src_new" "$view_new" $debug`; - if (!-e $view_new) { - if ($opt_d >= 1) { print "$tab"." failed to create $view_new; using real marker\n"; } - $view_new = ""; - } + $view = "$tmpdir/cfg-update-$$-$safe_role-$safe"; + if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." cp -pP \"$src\" \"$view\"\n"; } + `cp -pP "$src" "$view" $debug`; + if (!-e $view) { + if ($opt_d >= 1) { print "$tab"." failed to create $view; using real path\n"; } + $view = ""; } if ($opt_d >= 1) { - print "$tab"." path_view_ancestor = $view_ancestor\n"; - print "$tab"." path_view_new = $view_new\n"; - $tab =~ s/ //; print "$tab"."\n"; + print "$tab"." path_view = $view\n"; + $tab =~ s/ //; print "$tab"."\n"; } - return ($view_ancestor, $view_new); + return $view; } -sub cleanup_merge_view_temps { #ARGS# ($view_ancestor, $view_new) - if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; } - my ($view_ancestor, $view_new) = @_; - if (defined $view_ancestor && $view_ancestor ne "" && -e $view_ancestor) { - if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$view_ancestor\"\n"; } - `rm -f "$view_ancestor" $debug`; - } - if (defined $view_new && $view_new ne "" && -e $view_new) { - if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$view_new\"\n"; } - `rm -f "$view_new" $debug`; - } - if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."\n"; } +sub cleanup_merge_view_temp { #ARGS# ($view_path) + if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; } + my ($view) = @_; + if (defined $view && $view ne "" && -e $view) { + if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$view\"\n"; } + `rm -f "$view" $debug`; + } + if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."\n"; } +} + +# Unlink zero or more disposable merge-view paths (convenience wrapper). +sub cleanup_merge_view_temps { + my @views = @_; + foreach my $view (@views) { + cleanup_merge_view_temp($view); + } } # Launch merge tool for Stage 3 with disposable /tmp views of ancestor + marker. @@ -1023,9 +1020,11 @@ sub launch_tool_with_merge_view_temps { #ARGS# ("pretend|execute","mergetool") my ($mode, $tool) = @_; my $real_backup_new = $path_backup_new; my $real_new = $path_new; - my ($view_ancestor, $view_new) = ("", ""); + my $view_ancestor = ""; + my $view_new = ""; if ($mode =~ /execute/) { - ($view_ancestor, $view_new) = make_merge_view_temps($cfg_basename, $path_backup_new, $path_new); + $view_ancestor = make_merge_view_temp($path_backup_new, "ancestor", $cfg_basename); + $view_new = make_merge_view_temp($path_new, "new", $cfg_basename); if ($view_ancestor ne "") { $path_backup_new = $view_ancestor; } if ($view_new ne "") { $path_new = $view_new; } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 287f3e2..bd2790d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -176,7 +176,7 @@ No conflict markers → apply merged result. Conflict → defer to stage 3. Launches the configured merge tool with ancestor, live, and new files. Requires a tool with 3-way support (meld, kdiff3, xxdiff, tkdiff, imediff). -For Stage 3 only, the ancestor (`$path_backup_new`) and Portage marker (`$path_new`) are copied to disposable files under `/tmp` (or `$TMPDIR`) before the tool runs, so accidental saves on non-live panes cannot corrupt permanent backups or markers. The live file and `*.merge` output paths remain real. See issue #65. +For Stage 3, the ancestor (`$path_backup_new`) and Portage marker (`$path_new`) are each copied via `make_merge_view_temp` to disposable files under `/tmp` (or `$TMPDIR`) before the tool runs, so accidental saves on non-live panes cannot corrupt permanent backups or markers. The live file and `*.merge` output paths remain real. See issue #65. Stage 4 marker-only views are tracked in issue #68. ### Stage 4 — Manual 2-way merge (`update_stage4`) From 3250bbe41566a83aa17fac064d0c581c28024463 Mon Sep 17 00:00:00 2001 From: Richard Freeman Date: Sat, 18 Jul 2026 12:07:34 +0000 Subject: [PATCH 4/8] feature: validate ._cfg* markers against Portage CONTENTS before ancestor promote (issue #66) Before promoting a staged marker snapshot to ._new-cfg_*, compare its MD5 to the Portage VDB CONTENTS digest for the live path (last-install BUILD_TIME). Mismatch skips ancestor promotion with a warning; missing VDB fails open. New helpers take all inputs via parameters/returns. --- cfg-update | 135 +++++++++++++++++++++++++++++++++++++++++-- docs/ARCHITECTURE.md | 9 +++ docs/INVENTORY.md | 1 + test/README.md | 3 +- test/run-tests.sh | 103 +++++++++++++++++++++++++++++++++ 5 files changed, 244 insertions(+), 7 deletions(-) diff --git a/cfg-update b/cfg-update index a0b0bea..ce930b9 100755 --- a/cfg-update +++ b/cfg-update @@ -955,6 +955,126 @@ sub make_temp_backups{ #ARGS# ("pretend|execute") if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."\n"; } } +# Look up Portage VDB CONTENTS MD5 for the live config path (issue #66). +# Uses the package with the highest BUILD_TIME (last install); falls back to +# CONTENTS mtime when BUILD_TIME is missing. Does not read checksum.index. +# Returns ($status, $md5_or_reason): status is "ok" or "unavailable". +# All inputs via parameters — no package globals. +sub lookup_contents_md5_for_live { #ARGS# ($path_live, $pkg_manager, $pkg_db) + my ($path_live, $pkg_manager, $pkg_db) = @_; + if (!defined $path_live || $path_live eq "") { + return ("unavailable", "empty path"); + } + if (!defined $pkg_manager || $pkg_manager !~ /^portage$/i) { + return ("unavailable", "not Portage"); + } + if (!defined $pkg_db || $pkg_db eq "" || !-d $pkg_db) { + return ("unavailable", "no pkg_db"); + } + + my $best_md5; + my $best_time = -1; + + opendir(my $cat_dh, $pkg_db) or return ("unavailable", "cannot open pkg_db"); + while (defined(my $category = readdir($cat_dh))) { + next if $category eq "." || $category eq ".."; + my $cat_path = "$pkg_db/$category"; + next unless -d $cat_path; + opendir(my $pkg_dh, $cat_path) or next; + while (defined(my $pkg = readdir($pkg_dh))) { + next if $pkg eq "." || $pkg eq ".."; + my $pkg_path = "$cat_path/$pkg"; + my $contents = "$pkg_path/CONTENTS"; + next unless -f $contents; + + my $install_time = 0; + if (open(my $bt, "<", "$pkg_path/BUILD_TIME")) { + my $line = <$bt>; + close($bt); + if (defined $line && $line =~ /^\s*(\d+)\s*$/) { + $install_time = $1 + 0; + } + } + if ($install_time <= 0) { + $install_time = (stat($contents))[9] // 0; + } + + open(my $fh, "<", $contents) or next; + while (defined(my $line = <$fh>)) { + # Portage: obj + if ($line =~ /^obj\s+(\S+)\s+([0-9a-fA-F]{32})\b/) { + my ($obj_path, $md5) = ($1, lc($2)); + if ($obj_path eq $path_live && $install_time >= $best_time) { + $best_time = $install_time; + $best_md5 = $md5; + } + } + } + close($fh); + } + closedir($pkg_dh); + } + closedir($cat_dh); + + if (!defined $best_md5) { + return ("unavailable", "no CONTENTS match"); + } + return ("ok", $best_md5); +} + +# Promote staged marker snapshot ($path_temp_new) to permanent Stage 2 ancestor +# ($path_backup_new) only when it still matches Portage CONTENTS for $path_live +# (issue #66). On mismatch: warn and skip promote. On missing VDB: fail open. +# Returns 1 if promoted (or would under pretend), 0 if skipped for mismatch. +# All inputs via parameters — no package globals. Caller still removes temp_new. +sub promote_backup_new { #ARGS# ($mode, $path_temp_new, $path_backup_new, $path_live, $pkg_manager, $pkg_db, $enable_backups, $indent) + my ($mode, $path_temp_new, $path_backup_new, $path_live, + $pkg_manager, $pkg_db, $enable_backups, $indent) = @_; + $indent = "" if !defined $indent; + $mode = "" if !defined $mode; + + if (!defined $enable_backups || $enable_backups !~ /^yes$|^true$|^on$/i) { + return 1; + } + if (!defined $path_temp_new || $path_temp_new eq "" || !-e $path_temp_new) { + return 1; + } + if (!defined $path_backup_new || $path_backup_new eq "") { + return 1; + } + + my $marker_md5 = ""; + { + my $out = `md5sum "$path_temp_new" 2>/dev/null`; + if (defined $out && $out =~ /^([0-9a-fA-F]{32})\b/) { + $marker_md5 = lc($1); + } + } + + my ($status, $detail) = lookup_contents_md5_for_live($path_live, $pkg_manager, $pkg_db); + + if ($status eq "ok") { + my $contents_md5 = $detail; + if ($marker_md5 ne "" && $marker_md5 eq $contents_md5) { + if ($mode =~ /execute/) { + `cp -pP "$path_temp_new" "$path_backup_new" 2>/dev/null`; + } + return 1; + } + print BOLD YELLOW "$indent"."* Warning: Portage marker does not match CONTENTS MD5 for $path_live\n"; + print "$indent"." marker MD5=$marker_md5 CONTENTS MD5=$contents_md5\n"; + print BOLD YELLOW "$indent"."* Not promoting tampered marker as Stage 2 ancestor (._new-cfg_*)\n"; + return 0; + } + + # unavailable: fail open (maintain prior promote behavior) + print BOLD YELLOW "$indent"."* Warning: cannot validate marker against Portage CONTENTS ($detail); promoting ancestor as usual\n"; + if ($mode =~ /execute/) { + `cp -pP "$path_temp_new" "$path_backup_new" 2>/dev/null`; + } + return 1; +} + # Disposable /tmp view of one non-live merge input so accidental tool saves cannot # corrupt permanent backups or Portage markers (issue #65; Stage 4: issue #68). # Orthogonal to make_temp_backups (which stages permanent backup promotion). @@ -1545,8 +1665,9 @@ sub update_merge_complete{ #ARGS# ("pretend|execute") if ($_[0] =~ /execute/) { `cp -pP "$path_temp_old" "$path_backup_old" $debug`; } if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$path_temp_old\"\n"; } if ($_[0] =~ /execute/) { `rm -f "$path_temp_old" $debug`; } - if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." cp -pP \"$path_temp_new\" \"$path_backup_new\"\n"; } - if ($_[0] =~ /execute/) { `cp -pP "$path_temp_new" "$path_backup_new" $debug`; } + # Validate marker vs Portage CONTENTS before writing Stage 2 ancestor (issue #66). + promote_backup_new($_[0], $path_temp_new, $path_backup_new, $path_live, + $pkg_manager, $pkg_db, $enable_backups, $tab); if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$path_temp_new\"\n"; } if ($_[0] =~ /execute/) { `rm -f "$path_temp_new" $debug`; } if (($opt_v >= 1) || ($opt_d >= 1)) { if ($is_executable =~ "yes") { print "$tab"." chmod +x $path_live\n"; }} @@ -1571,8 +1692,9 @@ sub update_replace_complete{ #ARGS# ("pretend|execute") if ($_[0] =~ /execute/) { `cp -pP "$path_temp_old" "$path_backup_old" $debug`; } if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$path_temp_old\"\n"; } if ($_[0] =~ /execute/) { `rm -f "$path_temp_old" $debug`; } - if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." cp -pP \"$path_temp_new\" \"$path_backup_new\"\n"; } - if ($_[0] =~ /execute/) { `cp -pP "$path_temp_new" "$path_backup_new" $debug`; } + # Validate marker vs Portage CONTENTS before writing Stage 2 ancestor (issue #66). + promote_backup_new($_[0], $path_temp_new, $path_backup_new, $path_live, + $pkg_manager, $pkg_db, $enable_backups, $tab); if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$path_temp_new\"\n"; } if ($_[0] =~ /execute/) { `rm -f "$path_temp_new" $debug`; } if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$path_merged\" (should not exist!)\n"; } @@ -1592,8 +1714,9 @@ sub update_keep_complete{ #ARGS# ("pretend|execute") if ($_[0] =~ /execute/) { `cp -pP "$path_temp_old" "$path_backup_old" $debug`; } if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$path_temp_old\"\n"; } if ($_[0] =~ /execute/) { `rm -f "$path_temp_old" $debug`; } - if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." cp -pP \"$path_temp_new\" \"$path_backup_new\"\n"; } - if ($_[0] =~ /execute/) { `cp -pP "$path_temp_new" "$path_backup_new" $debug`; } + # Validate marker vs Portage CONTENTS before writing Stage 2 ancestor (issue #66). + promote_backup_new($_[0], $path_temp_new, $path_backup_new, $path_live, + $pkg_manager, $pkg_db, $enable_backups, $tab); if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$path_temp_new\"\n"; } if ($_[0] =~ /execute/) { `rm -f "$path_temp_new" $debug`; } if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$path_merged\" (should not exist)\n"; } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bd2790d..09012b1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -205,6 +205,15 @@ Backups live under `/var/lib/cfg-update/backups/`, mirroring the original path: These enable stage 2's 3-way merges on subsequent updates. +Before writing `._new-cfg_*`, cfg-update compares the staged marker snapshot +(`$path_temp_new`) to the Portage VDB `CONTENTS` MD5 for the **live path** +(not the checksum index, and not the `._cfg*` filename). The digest is taken +from the most recently installed package that owns that path (`BUILD_TIME`, +else CONTENTS mtime). On match, the ancestor is promoted as usual. On +mismatch, a warning is printed and the bad content is **not** stored as the +Stage 2 ancestor (issue #66). If CONTENTS is missing, unparseable, or the +manager is not Portage, promotion fails open (prior behavior) with a warning. + | Command | Purpose | |---------|---------| | `-b` / `--backups` | List available backups | diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index a11e11f..913360f 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -106,6 +106,7 @@ Every normal invocation (unless `--ebuild`) runs `check_hooks` and `check_tool` | `update_stage1`–`update_stage5` | Per-stage logic | | `update_retry`, `update_canceled`, `update_merge_*`, `update_replace_complete`, `update_keep_complete` | Interactive update handlers | | `make_temp_backups` | Temp files during merge | +| `lookup_contents_md5_for_live`, `promote_backup_new` | Validate marker vs Portage CONTENTS before Stage 2 ancestor promote (issue #66) | ### Index and hooks diff --git a/test/README.md b/test/README.md index e914898..71d8ebf 100644 --- a/test/README.md +++ b/test/README.md @@ -62,8 +62,9 @@ FEATURES=test USE=test emerge --oneshot app-portage/cfg-update | D | `-u` + stdin | Stages 3–5 execute (one stage enabled at a time): stage-specific output, mock 3-way merge, replace/keep filesystem outcomes | | E | `-i` / `-i -f` | Portage `--index`: up-to-date skip, stale rebuild from mock CONTENTS, marker-blocked skip, force rebuild | | F | `-b`, `-r`, `--optimize-backups` | Backup list/restore after stage-2 update; stage-1 backups land in `BACKUP_PATH` (not inline); optimize-backups creates `._new-cfg_*` for unmodified files | +| G | CONTENTS promote validation | Issue #66: pristine marker promotes `._new-cfg_*`; tampered marker does not poison ancestor; missing CONTENTS fails open; last-install `BUILD_TIME` wins | -Tier B/C/D/E/F pass `--testsandbox` with `--ebuild` so `-u`, `--index`, `-r`, and `--optimize-backups` skip the root check inside the temp sandbox. +Tier B/C/D/E/F/G pass `--testsandbox` with `--ebuild` so `-u`, `--index`, `-r`, and `--optimize-backups` skip the root check inside the temp sandbox. ### Golden `expected/` files diff --git a/test/run-tests.sh b/test/run-tests.sh index 80cfebf..c55f97e 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -143,6 +143,7 @@ write_test_config() { local conf="$1" index="$2" backup="$3" local stages="${4:-all}" local merge_tool="${5:-/usr/bin/diff3}" + local pkg_db="${6:-}" local s1=yes s2=yes s3=yes s4=yes s5=yes if [[ "$stages" == "auto" ]]; then s3=no s4=no s5=no @@ -166,6 +167,23 @@ ENABLE_STAGE5 = $s5 INDEX_FILE = $index BACKUP_PATH = $backup EOF + if [[ -n "$pkg_db" ]]; then + echo "PKG_DB = $pkg_db" >>"$conf" + fi +} + +# Install a Portage CONTENTS entry for a live path (issue #66 promote validation). +# Args: live_abs_path md5 [pkg_slot_dir relative to SANDBOX/var/db/pkg] [BUILD_TIME] +install_contents_obj() { + local live_path="$1" + local md5="$2" + local slot_rel="${3:-app-test/test-pkg-1.0}" + local build_time="${4:-1000}" + local pkg_dir="$SANDBOX/var/db/pkg/$slot_rel" + mkdir -p "$pkg_dir" + # Append so multi-package scenarios can stack entries in separate slots. + echo "obj $live_path $md5 0" >>"$pkg_dir/CONTENTS" + echo "$build_time" >"$pkg_dir/BUILD_TIME" } install_portageq_mock() { @@ -1129,6 +1147,90 @@ tier_e_index_portage() { 'Stage\[1\][[:space:]]+Unmodified File[[:space:]].*_cfg0000_test_unmodified_file' "$output" } +tier_g_contents_promote_validation() { + echo "=== Tier G: CONTENTS marker promote validation (issue #66) ===" + local output backup_root live marker_md5 pristine_marker + + # --- Pristine marker: CONTENTS matches → promote ._new-cfg_* --- + setup_sandbox stage1-unmodified-text auto + live="$SANDBOX/etc/test/test_unmodified_file" + pristine_marker="$SANDBOX/etc/test/._cfg0000_test_unmodified_file" + marker_md5="$(md5sum "$pristine_marker" | awk '{print $1}')" + install_contents_obj "$live" "$marker_md5" "app-test/test-pkg-1.0" "2000" + # Point PKG_DB at sandbox VDB (not host /var/db/pkg). + echo "PKG_DB = $SANDBOX/var/db/pkg" >>"$SANDBOX/etc/cfg-update.conf" + + run_cfg_update -au >/dev/null + backup_root="$SANDBOX/var/lib/cfg-update/backups${SANDBOX}/etc/test" + assert_file_exists "pristine CONTENTS: promoted new-cfg ancestor" \ + "$backup_root/._new-cfg_test_unmodified_file" + assert_md5_equals "pristine CONTENTS: ancestor MD5 equals marker" \ + "$backup_root/._new-cfg_test_unmodified_file" "$marker_md5" + assert_missing "pristine CONTENTS: cfg marker removed after stage1" \ + "$SANDBOX/etc/test/._cfg0000_test_unmodified_file" + + # --- Tampered marker: CONTENTS still pristine → do not poison ancestor --- + setup_sandbox stage1-unmodified-text auto + live="$SANDBOX/etc/test/test_unmodified_file" + pristine_marker="$SANDBOX/etc/test/._cfg0000_test_unmodified_file" + marker_md5="$(md5sum "$pristine_marker" | awk '{print $1}')" + install_contents_obj "$live" "$marker_md5" "app-test/test-pkg-1.0" "2000" + echo "PKG_DB = $SANDBOX/var/db/pkg" >>"$SANDBOX/etc/cfg-update.conf" + # External pre-session tamper (the footgun issue #66 covers). + printf 'TAMPERED-MARKER\n' >"$pristine_marker" + + output="$(run_cfg_update -au 2>&1)" || true + backup_root="$SANDBOX/var/lib/cfg-update/backups${SANDBOX}/etc/test" + assert_output_matches "tampered marker: CONTENTS mismatch warning" \ + 'does not match CONTENTS MD5' "$output" + assert_output_matches "tampered marker: refused Stage 2 ancestor promote" \ + 'Not promoting tampered marker' "$output" + # Stage1 still applies the (tampered) marker to the live file. + assert_file_contains "tampered marker: live update still completed" \ + "$live" "TAMPERED-MARKER" + assert_missing "tampered marker: cfg marker still removed" \ + "$SANDBOX/etc/test/._cfg0000_test_unmodified_file" + if [[ -f "$backup_root/._new-cfg_test_unmodified_file" ]]; then + if grep -q 'TAMPERED-MARKER' "$backup_root/._new-cfg_test_unmodified_file"; then + fail "tampered marker: ancestor must not contain TAMPERED content" + else + pass "tampered marker: existing ancestor not poisoned with TAMPERED" + fi + else + pass "tampered marker: no new-cfg ancestor written" + fi + + # --- Missing CONTENTS: fail open → still promote (regression guard) --- + setup_sandbox stage1-unmodified-text auto + # Explicit empty pkg_db so host VDB cannot interfere. + mkdir -p "$SANDBOX/var/db/pkg-empty" + echo "PKG_DB = $SANDBOX/var/db/pkg-empty" >>"$SANDBOX/etc/cfg-update.conf" + output="$(run_cfg_update -au 2>&1)" || true + backup_root="$SANDBOX/var/lib/cfg-update/backups${SANDBOX}/etc/test" + assert_output_matches "missing CONTENTS: fail-open validation warning" \ + 'cannot validate marker against Portage CONTENTS' "$output" + assert_file_exists "missing CONTENTS: still promoted new-cfg ancestor" \ + "$backup_root/._new-cfg_test_unmodified_file" + + # --- Multi-package: last install (higher BUILD_TIME) wins --- + setup_sandbox stage1-unmodified-text auto + live="$SANDBOX/etc/test/test_unmodified_file" + pristine_marker="$SANDBOX/etc/test/._cfg0000_test_unmodified_file" + marker_md5="$(md5sum "$pristine_marker" | awk '{print $1}')" + # Older package has a wrong/stale MD5; newer package has the real marker MD5. + install_contents_obj "$live" "00000000000000000000000000000000" \ + "app-test/old-pkg-1.0" "1000" + install_contents_obj "$live" "$marker_md5" \ + "app-test/new-pkg-2.0" "9000" + echo "PKG_DB = $SANDBOX/var/db/pkg" >>"$SANDBOX/etc/cfg-update.conf" + run_cfg_update -au >/dev/null + backup_root="$SANDBOX/var/lib/cfg-update/backups${SANDBOX}/etc/test" + assert_file_exists "last-install CONTENTS: promoted new-cfg ancestor" \ + "$backup_root/._new-cfg_test_unmodified_file" + assert_md5_equals "last-install CONTENTS: ancestor matches newer package MD5" \ + "$backup_root/._new-cfg_test_unmodified_file" "$marker_md5" +} + main() { parse_args "$@" @@ -1155,6 +1257,7 @@ main() { tier_d_execute_manual tier_e_index_portage tier_f_backups_maintenance + tier_g_contents_promote_validation echo "" echo "Results: $PASS passed, $FAIL failed, $SKIP skipped" From 0a88b98a9a01d5deb5171c087000491bffdffc33 Mon Sep 17 00:00:00 2001 From: Richard Freeman Date: Sat, 18 Jul 2026 14:03:18 +0000 Subject: [PATCH 5/8] docs: optional MentisDB memory guidance in AGENTS.md Capability-gated instructions so agents with MentisDB use chain cfg-update automatically; agents without it skip the section. --- AGENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index bdcbf92..b12c9a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,3 +40,16 @@ All work on **topic branches** → PR to `develop`. perl -c cfg-update ./test/run-tests.sh --full ``` + +## Optional: MentisDB memory + +If MentisDB MCP tools are available in this session: +- Use chain_key `cfg-update` (do not create another chain for this repo). +- Session start: list_chains → bootstrap → skill_md → list_agents → recent_context → Summary checkpoint. +- Reuse an existing agent_id; do not invent new agent IDs without maintainer approval. +- Persist Decision / Constraint / LessonLearned / TaskComplete / Checkpoint as work progresses. +- Search before append; one strong memory beats many weak ones. + +If MentisDB tools are not available: **ignore this section completely**. +Do not invent a substitute, do not fail, and do not ask the user to install MentisDB +unless they explicitly want project memory. From 3f6111547bd55f86370f2d97933bf58870b6813b Mon Sep 17 00:00:00 2001 From: Richard Freeman Date: Sat, 18 Jul 2026 14:29:42 +0000 Subject: [PATCH 6/8] feature: Stage 4 merge tools use /tmp view of Portage marker (issue #68) Reuse launch_tool_with_merge_view_temps so interactive 2-way tools receive a disposable $TMPDIR copy of $path_new, not the real ._cfg* marker. Live and *.merge paths stay real. Extend tool_intro, docs, and Tier D path/hostile coverage. --- cfg-update | 29 +++++---- docs/ARCHITECTURE.md | 4 +- test/run-tests.sh | 144 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 157 insertions(+), 20 deletions(-) diff --git a/cfg-update b/cfg-update index ce930b9..9378dca 100755 --- a/cfg-update +++ b/cfg-update @@ -1132,7 +1132,9 @@ sub cleanup_merge_view_temps { } } -# Launch merge tool for Stage 3 with disposable /tmp views of ancestor + marker. +# Launch merge tool for Stage 3/4 with disposable /tmp views of non-live inputs. +# Stage 3: ancestor ($path_backup_new) + marker ($path_new). Stage 4: marker only +# (ancestor view is a no-op when the backup file does not exist). # Temporarily rebinds existing $path_backup_new / $path_new for launch_tool only, then # restores them so complete/cancel paths still use the real marker and ancestor. sub launch_tool_with_merge_view_temps { #ARGS# ("pretend|execute","mergetool") @@ -1441,7 +1443,7 @@ sub update_stage4{ #ARGS# ("pretend|execute") if ($key =~ /v|y/) { &tool_intro($merge_tool_name); if ($merge_tool_name =~ /^diff$|^sdiff$/) { print "$tab"."$bar2\n"; } - &launch_tool($_[0],$merge_tool); + &launch_tool_with_merge_view_temps($_[0],$merge_tool); if ($merge_tool_name =~ /^diff$|^sdiff$/) { print "$tab"."$bar2\n"; print "$tab"." To scroll up and down use [Shift]+[PgUp] and [Shift]+[PgDn]\n"; } if (-e $path_merged) { if ($tool_saves_mergefile_when_aborted =~ "no") { @@ -2006,6 +2008,8 @@ sub tool_intro{ #ARGS# ("mergetoolname") print "$tab"." press [enter] to view all merge/edit options\n"; print "$tab"." press [l],[enter] to select the left line(s) from the current file\n"; print "$tab"." press [r],[enter] to select the right line(s) from the new file\n"; + print "$tab"." The right (new-file) side is a temporary copy; accidental edits there\n"; + print "$tab"." are discarded. Save the merge output only.\n"; print "$tab"." When $_[0] is done, $progname will ask if you want to complete or cancel\n"; print "$tab"." the update...\n"; } elsif ($_[0] =~ /^diff3$/) { @@ -2017,14 +2021,14 @@ sub tool_intro{ #ARGS# ("mergetoolname") print "$tab"." In $_[0] you select the lines that you want to keep by simply\n"; print "$tab"." clicking on the colored lines. They will appear in the merge-pane.\n"; print "$tab"." When done, click the M-button to save the result, then exit $_[0]!\n"; - print "$tab"." In 3-way mode the ancestor and new-file panes are temporary copies;\n"; - print "$tab"." accidental edits there are discarded. Save the merge output only.\n"; + print "$tab"." The new-file pane is a temporary copy (in 3-way mode the ancestor pane\n"; + print "$tab"." is too); accidental edits there are discarded. Save the merge output only.\n"; print "$tab"." When you exit $_[0], $progname will finish the update.\n"; } elsif ($_[0] =~ /^kdiff3$/) { print "$tab"." In $_[0] you select the lines that you want to keep.\n"; print "$tab"." When done, click the save button and exit $_[0]!\n"; - print "$tab"." In 3-way mode the ancestor and new-file panes are temporary copies;\n"; - print "$tab"." accidental edits there are discarded. Save the merge output only.\n"; + print "$tab"." The new-file pane is a temporary copy (in 3-way mode the ancestor pane\n"; + print "$tab"." is too); accidental edits there are discarded. Save the merge output only.\n"; print "$tab"." When you exit $_[0], $progname will finish the update...\n"; } elsif ($_[0] =~ /^kompare$/) { print "$tab"." In $_[0] you select the lines that you want to keep.\n"; @@ -2042,25 +2046,26 @@ sub tool_intro{ #ARGS# ("mergetoolname") print "$tab"." press [esc] return to command mode (when in edit mode)\n"; print "$tab"." press [:][q][a][!] to close $_[0] without saving\n"; print "$tab"." press [:][w][q][a] to save changes and close $_[0]\n"; + print "$tab"." The right (new-file) window is a temporary copy; edit the left (live) window only.\n"; print "$tab"." When you exit $_[0], $progname will finish the update...\n"; } elsif ($_[0] =~ /^meld$/) { print "$tab"." In $_[0] you select the lines that you want to keep.\n"; print "$tab"." When done, save the merged result over the current configfile by\n"; print "$tab"." right-clicking on the left pane and chosing \"Save\"!\n"; - print "$tab"." In 3-way mode the middle (ancestor) and right (new) panes are\n"; - print "$tab"." temporary copies; accidental edits there are discarded.\n"; + print "$tab"." The new-file pane is a temporary copy (in 3-way mode the middle ancestor\n"; + print "$tab"." pane is too); accidental edits there are discarded.\n"; print "$tab"." When you exit $_[0], $progname will finish the update...\n"; } elsif ($_[0] =~ /^tkdiff$/) { print "$tab"." In $_[0] you select the lines that you want to keep.\n"; print "$tab"." When done, save the merged result with the \"Save & Exit\" button!\n"; - print "$tab"." In 3-way mode the ancestor and new-file panes are temporary copies;\n"; - print "$tab"." accidental edits there are discarded. Save the merge output only.\n"; + print "$tab"." The new-file pane is a temporary copy (in 3-way mode the ancestor pane\n"; + print "$tab"." is too); accidental edits there are discarded. Save the merge output only.\n"; print "$tab"." When you exit $_[0], $progname will finish the update...\n"; } elsif ($_[0] =~ /^imediff$/) { print "$tab"." In $_[0] you select the lines that you want to keep.\n"; print "$tab"." When done, the merged result is written to the *.merge output file.\n"; - print "$tab"." In 3-way mode the ancestor and new-file inputs are temporary copies;\n"; - print "$tab"." accidental edits there are discarded.\n"; + print "$tab"." The new-file input is a temporary copy (in 3-way mode the ancestor input\n"; + print "$tab"." is too); accidental edits there are discarded.\n"; print "$tab"." When you exit $_[0], $progname will finish the update...\n"; } else { print "$tab"." In $_[0] you select the lines that you want to keep.\n"; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 09012b1..15f1707 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -176,12 +176,14 @@ No conflict markers → apply merged result. Conflict → defer to stage 3. Launches the configured merge tool with ancestor, live, and new files. Requires a tool with 3-way support (meld, kdiff3, xxdiff, tkdiff, imediff). -For Stage 3, the ancestor (`$path_backup_new`) and Portage marker (`$path_new`) are each copied via `make_merge_view_temp` to disposable files under `/tmp` (or `$TMPDIR`) before the tool runs, so accidental saves on non-live panes cannot corrupt permanent backups or markers. The live file and `*.merge` output paths remain real. See issue #65. Stage 4 marker-only views are tracked in issue #68. +For Stage 3, the ancestor (`$path_backup_new`) and Portage marker (`$path_new`) are each copied via `make_merge_view_temp` to disposable files under `/tmp` (or `$TMPDIR`) before the tool runs, so accidental saves on non-live panes cannot corrupt permanent backups or markers. The live file and `*.merge` output paths remain real. See issue #65. ### Stage 4 — Manual 2-way merge (`update_stage4`) Merges live file and `._cfg*` update when no backup exists. Works with all supported tools. +For Stage 4, the Portage marker (`$path_new`) is copied via `make_merge_view_temp` to a disposable file under `/tmp` (or `$TMPDIR`) before the interactive tool runs (same helper as Stage 3; ancestor view is a no-op when no backup ancestor exists). Accidental saves on the new-file pane cannot corrupt the real `._cfg*` marker. The live file and `*.merge` output paths remain real. See issue #68. + ### Stage 5 — Manual special cases (`update_stage5`) Interactive prompts for binaries, symlinks, and custom files. diff --git a/test/run-tests.sh b/test/run-tests.sh index c55f97e..de215d9 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -411,13 +411,22 @@ case "${#files[@]}" in echo "ANCESTOR=${files[1]}" >>"$log" echo "NEW=${files[2]}" >>"$log" ;; - 2) echo "TWO_WAY=yes" >>"$log" ;; + 2) + echo "TWO_WAY=yes" >>"$log" + echo "LIVE=${files[0]}" >>"$log" + echo "NEW=${files[1]}" >>"$log" + ;; esac -# Hostile mode: overwrite tool inputs to simulate accidental pane saves (issue #65). -if [[ "${CFG_UPDATE_MOCK_TRASH_INPUTS:-}" == "1" && ${#files[@]} -eq 3 ]]; then - [[ -f "${files[1]}" ]] && echo "TRASHED-ANCESTOR" >"${files[1]}" - [[ -f "${files[2]}" ]] && echo "TRASHED-NEW" >"${files[2]}" - echo "TRASHED_INPUTS=yes" >>"$log" +# Hostile mode: overwrite tool inputs to simulate accidental pane saves (issues #65/#68). +if [[ "${CFG_UPDATE_MOCK_TRASH_INPUTS:-}" == "1" ]]; then + if [[ ${#files[@]} -eq 3 ]]; then + [[ -f "${files[1]}" ]] && echo "TRASHED-ANCESTOR" >"${files[1]}" + [[ -f "${files[2]}" ]] && echo "TRASHED-NEW" >"${files[2]}" + echo "TRASHED_INPUTS=yes" >>"$log" + elif [[ ${#files[@]} -eq 2 ]]; then + [[ -f "${files[1]}" ]] && echo "TRASHED-NEW" >"${files[1]}" + echo "TRASHED_INPUTS=yes" >>"$log" + fi fi if [[ -n "$outfile" && -f "${CFG_UPDATE_TEST_SANDBOX}/golden.merge" ]]; then cp "${CFG_UPDATE_TEST_SANDBOX}/golden.merge" "$outfile" @@ -437,13 +446,32 @@ install_mock_sdiff() { log="${CFG_UPDATE_TEST_SANDBOX}/mock-sdiff.log" echo "$*" >>"$log" outfile="" +files=() while [[ $# -gt 0 ]]; do case "$1" in -o) outfile="$2"; shift 2 ;; - *) shift ;; + -w) shift 2 ;; # -w WIDTH + -d) shift ;; # flag, no arg + -*) shift ;; + *) files+=("$1"); shift ;; esac done +# sdiff -w WIDTH -d -o OUT live new → remaining args are live/new echo "TWO_WAY=yes" >>"$log" +if [[ ${#files[@]} -ge 2 ]]; then + echo "LIVE=${files[0]}" >>"$log" + echo "NEW=${files[1]}" >>"$log" +elif [[ ${#files[@]} -eq 1 ]]; then + echo "NEW=${files[0]}" >>"$log" +fi +# Hostile mode: trash new-file pane (issue #68). +if [[ "${CFG_UPDATE_MOCK_TRASH_INPUTS:-}" == "1" && ${#files[@]} -ge 2 ]]; then + [[ -f "${files[1]}" ]] && echo "TRASHED-NEW" >"${files[1]}" + echo "TRASHED_INPUTS=yes" >>"$log" +elif [[ "${CFG_UPDATE_MOCK_TRASH_INPUTS:-}" == "1" && ${#files[@]} -eq 1 ]]; then + [[ -f "${files[0]}" ]] && echo "TRASHED-NEW" >"${files[0]}" + echo "TRASHED_INPUTS=yes" >>"$log" +fi if [[ -n "$outfile" && -f "${CFG_UPDATE_TEST_SANDBOX}/golden.merge" ]]; then cp "${CFG_UPDATE_TEST_SANDBOX}/golden.merge" "$outfile" fi @@ -932,6 +960,23 @@ tier_d_execute_manual() { "$FIXTURES/stage4-manual-2way/expected/test_manual_2way" assert_missing "stage4 mock merge removed cfg0000 marker" \ "$SANDBOX/etc/test/._cfg0000_test_manual_2way" + # Stage 4: new-file arg is disposable /tmp view (issue #68), not real marker + local real_marker_s4 + real_marker_s4="$SANDBOX/etc/test/._cfg0000_test_manual_2way" + # Marker already removed after complete; re-run path asserts with kdiff3 below. + assert_file_contains "stage4 sdiff new arg is merge-view temp" \ + "$SANDBOX/mock-sdiff.log" "NEW=${SANDBOX}/tmp/cfg-update-" + if grep -q "NEW=${real_marker_s4}" "$SANDBOX/mock-sdiff.log" 2>/dev/null; then + fail "stage4 sdiff must not pass real marker path" + else + pass "stage4 sdiff did not pass real marker path" + fi + leftover="$(find "$SANDBOX/tmp" -name 'cfg-update-*' 2>/dev/null | wc -l)" + if [[ "$leftover" -eq 0 ]]; then + pass "stage4 sdiff cleaned up merge-view temps" + else + fail "stage4 sdiff left $leftover merge-view temp(s) under TMPDIR" + fi # Stage 4: mock imediff must run 2-way merge (-a -o live new) setup_sandbox stage4-manual-2way stage4_only @@ -955,6 +1000,91 @@ tier_d_execute_manual() { "$FIXTURES/stage4-manual-2way/expected/test_manual_2way" assert_missing "stage4 mock imediff merge removed cfg0000 marker" \ "$SANDBOX/etc/test/._cfg0000_test_manual_2way" + assert_file_contains "stage4 imediff new arg is merge-view temp" \ + "$SANDBOX/mock-imediff.log" "NEW=${SANDBOX}/tmp/cfg-update-" + if grep -q "NEW=${real_marker_s4}" "$SANDBOX/mock-imediff.log" 2>/dev/null; then + fail "stage4 imediff must not pass real marker path" + else + pass "stage4 imediff did not pass real marker path" + fi + + # Stage 4: mock kdiff3 2-way — path assert + cleanup (issue #68) + setup_sandbox stage4-manual-2way stage4_only + install_mock_kdiff3 "$FIXTURES/stage4-manual-2way/expected/test_manual_2way" + sed -i "s|^MERGE_TOOL = .*|MERGE_TOOL = $SANDBOX/bin/kdiff3|" "$SANDBOX/etc/cfg-update.conf" + real_marker_s4="$SANDBOX/etc/test/._cfg0000_test_manual_2way" + output="$(run_cfg_update_stdin $'y\n1\ny\n1\n' -u 2>&1)" || true + # Explicit MERGE_TOOL=kdiff3: no default diff3→sdiff switch (unlike assert_stage_output). + assert_output_matches "stage4 kdiff3 mock merge: stage banner" \ + "<< Stage4 >>" "$output" + assert_output_matches "stage4 kdiff3 mock merge: 2-way merge mode" \ + 'manual 2-way merging, starting' "$output" + assert_output_not_matches "stage4 kdiff3 mock merge: not 3-way mode" \ + 'manual 3-way merging, starting' "$output" + assert_output_not_matches "stage4 kdiff3 mock merge: no diff3 switch" \ + 'diff3 cannot be used for this stage, changing to sdiff' "$output" + assert_file_contains "stage4 kdiff3 used 2-way merge" \ + "$SANDBOX/mock-kdiff3.log" "THREE_WAY=no" + assert_file_contains "stage4 kdiff3 new arg is merge-view temp" \ + "$SANDBOX/mock-kdiff3.log" "NEW=${SANDBOX}/tmp/cfg-update-" + if grep -q "NEW=${real_marker_s4}" "$SANDBOX/mock-kdiff3.log" 2>/dev/null; then + fail "stage4 kdiff3 must not pass real marker path" + else + pass "stage4 kdiff3 did not pass real marker path" + fi + leftover="$(find "$SANDBOX/tmp" -name 'cfg-update-*' 2>/dev/null | wc -l)" + if [[ "$leftover" -eq 0 ]]; then + pass "stage4 kdiff3 cleaned up merge-view temps" + else + fail "stage4 kdiff3 left $leftover merge-view temp(s) under TMPDIR" + fi + assert_file_equals "stage4 kdiff3 mock merge matches golden" \ + "$SANDBOX/etc/test/test_manual_2way" \ + "$FIXTURES/stage4-manual-2way/expected/test_manual_2way" + assert_missing "stage4 kdiff3 mock merge removed cfg0000 marker" \ + "$SANDBOX/etc/test/._cfg0000_test_manual_2way" + + # Stage 4: hostile mock trashes new-file view; real marker must survive cancel + setup_sandbox stage4-manual-2way stage4_only + install_mock_kdiff3 # no golden → no $path_merged → cancel/finish prompt + sed -i "s|^MERGE_TOOL = .*|MERGE_TOOL = $SANDBOX/bin/kdiff3|" "$SANDBOX/etc/cfg-update.conf" + real_marker_s4="$SANDBOX/etc/test/._cfg0000_test_manual_2way" + local marker_before_s4 marker_after_s4 + marker_before_s4="$(md5sum "$real_marker_s4" | awk '{print $1}')" + # Two markers in fixture (0000 and 0001); cancel first after tool trash, then skip rest. + output="$(CFG_UPDATE_MOCK_TRASH_INPUTS=1 run_cfg_update_stdin $'y\ns\ns\n' -u 2>&1)" || true + assert_file_contains "stage4 hostile mock trashed inputs" \ + "$SANDBOX/mock-kdiff3.log" "TRASHED_INPUTS=yes" + marker_after_s4="$(md5sum "$real_marker_s4" | awk '{print $1}')" + if [[ "$marker_before_s4" == "$marker_after_s4" ]]; then + pass "stage4 hostile cancel left real marker intact" + else + fail "stage4 hostile cancel corrupted real marker" + fi + assert_file_exists "stage4 hostile cancel kept cfg marker" "$real_marker_s4" + + # Stage 4: hostile mock trashes inputs; successful merge still uses real paths + setup_sandbox stage4-manual-2way stage4_only + install_mock_kdiff3 "$FIXTURES/stage4-manual-2way/expected/test_manual_2way" + sed -i "s|^MERGE_TOOL = .*|MERGE_TOOL = $SANDBOX/bin/kdiff3|" "$SANDBOX/etc/cfg-update.conf" + # Sandbox --ebuild sets tool_saves_mergefile_when_aborted=no, so confirm with [1]. + # Two queued markers: complete first merge, then keep/skip second. + output="$(CFG_UPDATE_MOCK_TRASH_INPUTS=1 run_cfg_update_stdin $'y\n1\n2\n' -u 2>&1)" || true + assert_output_matches "stage4 hostile complete: stage banner" \ + "<< Stage4 >>" "$output" + assert_output_matches "stage4 hostile complete: 2-way merge mode" \ + 'manual 2-way merging, starting' "$output" + assert_output_not_matches "stage4 hostile complete: not 3-way mode" \ + 'manual 3-way merging, starting' "$output" + assert_output_not_matches "stage4 hostile complete: no diff3 switch" \ + 'diff3 cannot be used for this stage, changing to sdiff' "$output" + assert_file_equals "stage4 hostile complete matches golden" \ + "$SANDBOX/etc/test/test_manual_2way" \ + "$FIXTURES/stage4-manual-2way/expected/test_manual_2way" + assert_missing "stage4 hostile complete removed cfg0000 marker" \ + "$SANDBOX/etc/test/._cfg0000_test_manual_2way" + assert_file_contains "stage4 hostile complete used merge-view temps" \ + "$SANDBOX/mock-kdiff3.log" "NEW=${SANDBOX}/tmp/cfg-update-" # Stage 4: replace (MF, no ancestor — must not run stage 3/5 handlers) setup_sandbox stage4-manual-2way stage4_only From b40290d8840a0ff2ddb8458d333c4aa69ea36451 Mon Sep 17 00:00:00 2001 From: Richard Freeman Date: Tue, 28 Jul 2026 12:54:03 +0000 Subject: [PATCH 7/8] Release 1.11.0 Bump version strings and ebuild PV to 1.11.0. ChangeLog covers Stage 3/4 merge-view temps (#65, #68), CONTENTS ancestor promote validation (#66), and optional MentisDB AGENTS guidance. Sync INVENTORY/DEPENDENCIES ebuild paths to the new PV. --- ChangeLog | 12 ++++++++++++ README.md | 2 +- cfg-update | 2 +- docs/DEPENDENCIES.md | 6 +++--- docs/INVENTORY.md | 6 +++--- ...update-1.10.4.ebuild => cfg-update-1.11.0.ebuild} | 0 6 files changed, 20 insertions(+), 8 deletions(-) rename gentoo/{cfg-update-1.10.4.ebuild => cfg-update-1.11.0.ebuild} (100%) diff --git a/ChangeLog b/ChangeLog index e112bf6..3619315 100644 --- a/ChangeLog +++ b/ChangeLog @@ -2,6 +2,18 @@ # Copyright 2002-2005 Gentoo Technologies, Inc.; Distributed under the GPL v2 # $Header: $ +*cfg-update-1.11.0 (2026-07-28) + + Safety: Stage 3 and Stage 4 interactive merge tools receive disposable + /tmp views of non-live inputs (ancestor and/or Portage ._cfg* marker) + so accidental tool saves cannot corrupt permanent backups or markers + (issues #65, #68). Before promoting a staged marker to the Stage 2 + ancestor (._new-cfg_*), validate its MD5 against Portage VDB CONTENTS + for the live path; mismatch skips promote with a warning, missing + CONTENTS fails open (issue #66). Docs: optional MentisDB guidance in + AGENTS.md (PR #70). Expanded Tier D path/hostile and Tier G CONTENTS + promote tests. + *cfg-update-1.10.4 (2026-06-20) Drop gtkdiff and imediff2 merge-tool support; align merge-tool lists in diff --git a/README.md b/README.md index 9b1354d..f511e3a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Feedback is welcome. A safe, staged alternative to Gentoo's `etc-update` for handling configuration file updates after package merges. -**Version:** 1.10.4 +**Version:** 1.11.0 **License:** GPL-2 ([COPYING](COPYING)) ## Description diff --git a/cfg-update b/cfg-update index 9378dca..9127890 100755 --- a/cfg-update +++ b/cfg-update @@ -35,7 +35,7 @@ $Term::ANSIColor::AUTORESET = 1; ###################################################################################################################### # Setting program variables... - my $version = "1.10.4"; + my $version = "1.11.0"; my $progname = basename($0); my $debug = "2>/dev/null"; my $website = "https://github.com/rich0/cfg-update"; diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index a659033..1ceb004 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -122,10 +122,10 @@ Run from a git checkout (no root required): ./test/run-tests.sh ``` -Gentoo ebuild ([`gentoo/cfg-update-1.10.4.ebuild`](../gentoo/cfg-update-1.10.4.ebuild)): +Gentoo ebuild ([`gentoo/cfg-update-1.11.0.ebuild`](../gentoo/cfg-update-1.11.0.ebuild)): ```bash -FEATURES=test USE=test emerge --oneshot /path/to/gentoo/cfg-update-1.10.4.ebuild +FEATURES=test USE=test emerge --oneshot /path/to/gentoo/cfg-update-1.11.0.ebuild ``` | Requirement | Gentoo package | Used in | @@ -161,7 +161,7 @@ grep -q 'cfg-update --index' /etc/portage/bashrc && echo "hook OK" A reference ebuild is maintained in [`gentoo/`](../gentoo/). Install with: ```bash -FEATURES=test USE=test emerge --oneshot /path/to/gentoo/cfg-update-1.10.4.ebuild +FEATURES=test USE=test emerge --oneshot /path/to/gentoo/cfg-update-1.11.0.ebuild ``` Or from the Gentoo tree: `emerge app-portage/cfg-update`. diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 913360f..95a32e1 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -2,7 +2,7 @@ **Updated:** 2026-06-20 (issue #29) **Repo:** [rich0/cfg-update](https://github.com/rich0/cfg-update) -**Version:** 1.10.4 +**Version:** 1.11.0 **Target usage:** Single-host Gentoo with Portage (`emerge`); Paludis best-effort --- @@ -23,7 +23,7 @@ The repository has **no language-level lockfile** (no `package.json`, `cpanfile` | [`cfg-update.conf`](../cfg-update.conf) | 166 | Config template (installed as `/etc/cfg-update.conf`) | | [`cfg-update.8`](../cfg-update.8) | 149 | Man page | | [`cfg-update_indexing`](../cfg-update_indexing) | 12 | Paludis hook script (copied to `/usr/share/paludis/hooks/...`) | -| [`gentoo/cfg-update-1.10.4.ebuild`](../gentoo/cfg-update-1.10.4.ebuild) | — | Reference Gentoo ebuild with `src_test()` | +| [`gentoo/cfg-update-1.11.0.ebuild`](../gentoo/cfg-update-1.11.0.ebuild) | — | Reference Gentoo ebuild with `src_test()` | | [`ChangeLog`](../ChangeLog) | — | Gentoo ebuild changelog (historical) | | [`COPYING`](../COPYING) | — | GPL v2 | | [`test/run-tests.sh`](../test/run-tests.sh) | — | Integration test harness (Tiers 0–F) | @@ -220,7 +220,7 @@ See [DEPENDENCIES.md](DEPENDENCIES.md) for install commands. Summary: | Package | Purpose | |---------|---------| -| `app-portage/cfg-update` | Install via [`gentoo/cfg-update-1.10.4.ebuild`](../gentoo/cfg-update-1.10.4.ebuild) | +| `app-portage/cfg-update` | Install via [`gentoo/cfg-update-1.11.0.ebuild`](../gentoo/cfg-update-1.11.0.ebuild) | | `sys-apps/findutils` | `xargs` for index build | | `dev-util/meld` (recommended) | Default merge tool | | `dev-perl/Term-ANSIColor`, `dev-perl/TermReadKey` | Perl deps | diff --git a/gentoo/cfg-update-1.10.4.ebuild b/gentoo/cfg-update-1.11.0.ebuild similarity index 100% rename from gentoo/cfg-update-1.10.4.ebuild rename to gentoo/cfg-update-1.11.0.ebuild From 81c63f3f716bc367f8bd20aea1326dfed7a66311 Mon Sep 17 00:00:00 2001 From: rich0 Date: Tue, 28 Jul 2026 08:59:58 -0400 Subject: [PATCH 8/8] Clean up LLM-generated Changelog Made more accessible to the end user and described in terms of actual impact. Commit history still has the details of how it was done. --- ChangeLog | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/ChangeLog b/ChangeLog index 3619315..4a944f0 100644 --- a/ChangeLog +++ b/ChangeLog @@ -4,15 +4,12 @@ *cfg-update-1.11.0 (2026-07-28) - Safety: Stage 3 and Stage 4 interactive merge tools receive disposable - /tmp views of non-live inputs (ancestor and/or Portage ._cfg* marker) - so accidental tool saves cannot corrupt permanent backups or markers - (issues #65, #68). Before promoting a staged marker to the Stage 2 - ancestor (._new-cfg_*), validate its MD5 against Portage VDB CONTENTS - for the live path; mismatch skips promote with a warning, missing - CONTENTS fails open (issue #66). Docs: optional MentisDB guidance in - AGENTS.md (PR #70). Expanded Tier D path/hostile and Tier G CONTENTS - promote tests. + Safety checks added during all merges to prevent editing of any file versions + other than the leftmost one. This was always intended behavior but lacked + safeguards previously. Hashes are also checked to detect changes made + outside of cfg-update. These will still be preserved but will not be + relied upon in future automatic merges. The next clean package update + will return automatic merge behavior for that package. *cfg-update-1.10.4 (2026-06-20)