diff --git a/AGENTS.md b/AGENTS.md index 8d953ca..b12c9a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,3 +41,15 @@ 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. diff --git a/ChangeLog b/ChangeLog index e112bf6..4a944f0 100644 --- a/ChangeLog +++ b/ChangeLog @@ -2,6 +2,15 @@ # Copyright 2002-2005 Gentoo Technologies, Inc.; Distributed under the GPL v2 # $Header: $ +*cfg-update-1.11.0 (2026-07-28) + + 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) 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 dddb870..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"; @@ -955,6 +955,210 @@ 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). +# $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|/+$||; + if (!-d $tmpdir) { + if ($opt_d >= 1) { print "$tab"." mkdir -p \"$tmpdir\"\n"; } + `mkdir -p "$tmpdir" $debug`; + } + 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"; } + $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 = $view\n"; + $tab =~ s/ //; print "$tab"."\n"; + } + return $view; +} + +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/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") + if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; } + my ($mode, $tool) = @_; + my $real_backup_new = $path_backup_new; + my $real_new = $path_new; + my $view_ancestor = ""; + my $view_new = ""; + if ($mode =~ /execute/) { + $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; } + } + launch_tool($mode, $tool); + $path_backup_new = $real_backup_new; + $path_new = $real_new; + if ($mode =~ /execute/) { + cleanup_merge_view_temps($view_ancestor, $view_new); + } + 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 +1305,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"; @@ -1237,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") { @@ -1461,8 +1667,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"; }} @@ -1487,8 +1694,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"; } @@ -1508,8 +1716,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"; } @@ -1799,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$/) { @@ -1810,10 +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"." 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"." 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"; @@ -1831,15 +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"." 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"." 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"." 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 ea62aa4..15f1707 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -176,10 +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 — 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. @@ -203,6 +207,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/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 a11e11f..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) | @@ -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 @@ -219,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 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 3c5f412..de215d9 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() { @@ -267,8 +285,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 +309,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 +405,29 @@ if [[ "$use_a" == "yes" ]]; then echo "USE_A=yes" >>"$log" fi case "${#files[@]}" in - 3) echo "THREE_WAY=yes" >>"$log" ;; - 2) echo "TWO_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" + echo "LIVE=${files[0]}" >>"$log" + echo "NEW=${files[1]}" >>"$log" + ;; esac +# 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" fi @@ -389,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 @@ -780,6 +856,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 +941,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 @@ -809,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 @@ -832,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 @@ -1024,6 +1277,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 "$@" @@ -1050,6 +1387,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"