Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion cfg-update
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,88 @@ sub make_temp_backups{ #ARGS# ("pretend|execute")
if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."</make_temp_backups>\n"; }
}

# 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"."<make_merge_view_temp>\n"; $tab = $tab." "; }
my ($src, $role, $basename) = @_;
my $view = "";
if (!defined $src || $src eq "" || !-e $src) {
if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."</make_merge_view_temp>\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"."</make_merge_view_temp>\n";
}
return $view;
}

sub cleanup_merge_view_temp { #ARGS# ($view_path)
if ($opt_d >= 1) { print "$tab"."<cleanup_merge_view_temp>\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"."</cleanup_merge_view_temp>\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.
# 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"."<launch_tool_with_merge_view_temps>\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"."</launch_tool_with_merge_view_temps>\n"; }
}

sub update_stage1{ #ARGS# ("pretend|execute")
if ($opt_d >= 1) { print "$tab"."<update_stage1>\n"; $tab = $tab." "; }
if ($enable_stage1 !~ /^yes$|^true$|^on$/i) {
Expand Down Expand Up @@ -1101,7 +1183,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";
Expand Down Expand Up @@ -1810,10 +1894,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";
Expand All @@ -1836,10 +1924,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";
Expand Down
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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`)

Merges live file and `._cfg*` update when no backup exists. Works with all supported tools.
Expand Down
109 changes: 107 additions & 2 deletions test/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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[@]}"
}

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand All @@ -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
Expand Down
Loading