Create fettle.pl
This commit is contained in:
449
patches/fettle.pl
Executable file
449
patches/fettle.pl
Executable file
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env perl
|
||||
# ======================================================================
|
||||
# NAME: fettle.pl (Dynamic via $me)
|
||||
# PURPOSE: A high-integrity utility for making exact, precise, and
|
||||
# repeatable changes to files using only perl-base.
|
||||
# ======================================================================
|
||||
use strict;
|
||||
use warnings;
|
||||
use Getopt::Long;
|
||||
use File::Basename;
|
||||
|
||||
# Identify the script name dynamically for usage and error reporting
|
||||
my $me = basename($0);
|
||||
|
||||
# ======================================================================
|
||||
# DEPENDENCIES:
|
||||
# This utility requires only 'perl-base' and the following binaries
|
||||
# which are standard in Debian 12/13 base images:
|
||||
# - /usr/bin/cksum (from coreutils) for integrity verification
|
||||
# - /usr/bin/gzip (for compressed patch support)
|
||||
# ======================================================================
|
||||
|
||||
# --- 1. Global Constants & Indices ---
|
||||
# Stat array indices for readability and easy maintenance
|
||||
my ($ST_MODE, $ST_UID, $ST_GID, $ST_SIZE, $ST_MTIME) = (2, 4, 5, 7, 9);
|
||||
|
||||
# --- 2. Minimal Native Copy ---
|
||||
# Performs a binary-safe copy without external dependencies.
|
||||
# Used for creating temporary work files and original backups.
|
||||
sub native_copy {
|
||||
my ($source_path, $destination_path) = @_;
|
||||
open(my $in, '<', $source_path) or die "$me: Could not read $source_path: $!";
|
||||
open(my $out, '>', $destination_path) or die "$me: Could not write $destination_path: $!";
|
||||
binmode($in);
|
||||
binmode($out);
|
||||
print $out $_ while <$in>;
|
||||
close($in);
|
||||
close($out);
|
||||
}
|
||||
|
||||
# --- 3. Hashing Helpers ---
|
||||
# Internal helper to get a file's fingerprint using the system 'cksum' utility.
|
||||
sub _get_digest {
|
||||
my ($file_path, $algo) = @_;
|
||||
return "" unless -f $file_path;
|
||||
# Executes system cksum with specified algorithm (e.g., sha1, sha512).
|
||||
# Safely open a pipe without invoking a shell
|
||||
open(my $pipe, "-|", "/usr/bin/cksum", "-a", $algo, $file_path) or return "";
|
||||
my $cksum_output = <$pipe>;
|
||||
close($pipe);
|
||||
chomp($cksum_output);
|
||||
return "" unless $cksum_output;
|
||||
# Extract only the hex fingerprint from the tool's output.
|
||||
my ($fingerprint) = $cksum_output =~ /=\s+([a-f0-9]+)/i;
|
||||
return $fingerprint || "";
|
||||
}
|
||||
|
||||
# Specific aliases for readability: sha1 for patch ID, sha512 for file integrity.
|
||||
sub get_patch_id { return _get_digest(shift, "sha1"); }
|
||||
sub get_file_fingerprint { return _get_digest(shift, "sha512"); }
|
||||
|
||||
# --- 4. Configuration & Globals ---
|
||||
# Default fuzz_range allows the patcher to look 25 lines up/down for a match.
|
||||
my ($dry_run, $revert, $clean, $fuzz_range) = (0, 0, 0, 25);
|
||||
GetOptions(
|
||||
"dry-run" => \$dry_run, # Pre-calculates offsets and validates files
|
||||
"fuzz=i" => \$fuzz_range, # User-adjustable search range for line drifts
|
||||
"revert" => \$revert, # Restore files from .orig backups
|
||||
"clean" => \$clean # Delete .orig backups
|
||||
);
|
||||
|
||||
my $patch_file = $ARGV[0] or die "Usage: $me [--dry-run|--revert|--clean] <patch_file>\n";
|
||||
my $patch_hash = get_patch_id($patch_file);
|
||||
die "$me: Could not generate ID for patch file.\n" unless $patch_hash;
|
||||
|
||||
# Locate a writable directory for state tracking, preferring /cache if on a tmpfs mount.
|
||||
my $state_directory;
|
||||
if (open(my $mount_fh, '<', '/proc/mounts')) {
|
||||
while (my $mount_line = <$mount_fh>) {
|
||||
if ($mount_line =~ /^\S+\s+\/cache\s+tmpfs\s+/) {
|
||||
$state_directory = "/cache" if -w "/cache";
|
||||
last;
|
||||
}
|
||||
}
|
||||
close($mount_fh);
|
||||
}
|
||||
$state_directory ||= ($ENV{TMPDIR} && -d $ENV{TMPDIR} && -w _) ? $ENV{TMPDIR} : ".";
|
||||
|
||||
my $state_file = "$state_directory/.${me}_state_${patch_hash}";
|
||||
my $temp_suffix = substr($patch_hash, 0, 11);
|
||||
|
||||
# --- 5. Patch Parsing ---
|
||||
# Scans the patch file to build a map of files to be modified and their hunks.
|
||||
open(my $patch_fh, '<', $patch_file) or die "$me: Cannot open patch: $!\n";
|
||||
binmode($patch_fh);
|
||||
read($patch_fh, my $buffer, 2);
|
||||
close($patch_fh);
|
||||
if (defined $buffer && $buffer eq "\x1f\x8b") {
|
||||
open($patch_fh, "-|", "/usr/bin/gzip", "-dc", "--", $patch_file) or die "$me: Cannot open patch using zcat: $!";
|
||||
} else {
|
||||
open($patch_fh, '<', $patch_file) or die "$me: Cannot open patch: $!\n";
|
||||
}
|
||||
my %patches;
|
||||
my $current_file;
|
||||
my $is_git_format = 0;
|
||||
|
||||
while (my $line = <$patch_fh>) {
|
||||
# Detects git-style diff headers to properly handle additions/deletions.
|
||||
if ($line =~ /^diff --git\s+a\/.+?\s+b\/.+$/) {
|
||||
undef $current_file; # Reset context for high-integrity parsing
|
||||
$is_git_format = 1; next;
|
||||
}
|
||||
elsif ($is_git_format && $line =~ /^deleted file mode/) {
|
||||
$patches{$current_file}{deleted} = 1 if $current_file; next;
|
||||
}
|
||||
# Parse source ('---') and destination ('+++') file paths.
|
||||
elsif ($line =~ /^--- (?:a\/)?(.+)$/) {
|
||||
my $path = $1; $path =~ s/(?:\t.*|\s+)$//;
|
||||
$current_file = $path unless $path eq '/dev/null';
|
||||
}
|
||||
elsif ($line =~ /^\+\+\+ (?:b\/)?(.+)$/) {
|
||||
my $path = $1; $path =~ s/(?:\t.*|\s+)$//;
|
||||
if ($path eq '/dev/null') { $patches{$current_file}{deleted} = 1; }
|
||||
else { $current_file = $path; $patches{$current_file}{deleted} = 0; }
|
||||
}
|
||||
# Capture hunk headers: @@ -old_start,len +new_start,len @@
|
||||
# Handle hunk header with optional counts (default to 1)
|
||||
elsif ($current_file && $line =~ /^@@ \-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/) {
|
||||
push @{$patches{$current_file}{hunks}}, {
|
||||
old_start => $1,
|
||||
old_count => $2 // 1, # Default to 1 if count is missing
|
||||
new_start => $3,
|
||||
new_count => $4 // 1, # Default to 1 if count is missing
|
||||
lines => [],
|
||||
no_eof_newline => 0
|
||||
};
|
||||
}
|
||||
# Accumulate hunk content (context lines, additions, or deletions).
|
||||
elsif ($current_file && @{$patches{$current_file}{hunks}}) {
|
||||
if ($line =~ /^\\ No newline at end of file/) {
|
||||
$patches{$current_file}{hunks}[-1]{no_eof_newline} = 1;
|
||||
}
|
||||
else {
|
||||
push @{$patches{$current_file}{hunks}[-1]{lines}}, $line if $line =~ /^[ \+\-]/;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
close($patch_fh);
|
||||
|
||||
# --- 6. Clean and Revert ---
|
||||
# Logic for cleaning up or rolling back previously applied patches using the state file.
|
||||
my %state_metadata;
|
||||
if (-e $state_file) {
|
||||
open(my $sf_fh, '<', $state_file); <$sf_fh>; # Skip header
|
||||
while (<$sf_fh>) {
|
||||
chomp;
|
||||
my ($filename, $mtime, $size, $offsets, $status, $source_hash) = split(/\|/);
|
||||
$state_metadata{$filename} = { status => $status, hash => $source_hash };
|
||||
}
|
||||
close($sf_fh);
|
||||
}
|
||||
|
||||
if ($clean || $revert) {
|
||||
print(($clean ? "Cleaning backups...\n" : "Reverting to original state...\n"));
|
||||
my $errors = 0;
|
||||
foreach my $target (keys %patches) {
|
||||
my $backup = "$target.orig";
|
||||
if ($clean && -e $backup) {
|
||||
unlink($backup) or (warn("$me: Skip delete $backup: $!\n"), $errors++);
|
||||
}
|
||||
elsif ($revert && -e $backup) {
|
||||
# If the file was created by the patch, remove it entirely.
|
||||
if ($state_metadata{$target} && $state_metadata{$target}{status} eq "NEW") {
|
||||
# If the patch created it, delete it
|
||||
(unlink($target) or $errors++) if -e $target;
|
||||
unlink($backup) or $errors++;
|
||||
print " Removed created file: $target\n";
|
||||
} else {
|
||||
# Restore existing files from their .orig backup.
|
||||
# Attempt restoration of all files regardless of individual failures
|
||||
rename($backup, $target) or (warn("$me: Failed to restore $target: $!\n"), $errors++);
|
||||
print " Restored: $target\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unlink($state_file) if -e $state_file && $errors == 0;
|
||||
|
||||
# Exit with non-zero status if any part of the operation failed
|
||||
exit($errors > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
# --- 7. Hunk Matching Engine ---
|
||||
# Finds the correct line index in a file to apply a hunk, accounting for line drifts.
|
||||
sub find_hunk_index {
|
||||
my ($file_content, $hunk_lines, $start_pos) = @_;
|
||||
# Only use ' ' (context) and '-' (to-be-removed) lines for matching.
|
||||
my @match_search = grep { /^[ -]/ } @$hunk_lines;
|
||||
|
||||
# Try exact match first.
|
||||
return $start_pos if verify_context($file_content, \@match_search, $start_pos);
|
||||
|
||||
# Search within the 'fuzz' range for a shifted match.
|
||||
for (my $offset = 1; $offset <= $fuzz_range; $offset++) {
|
||||
return ($start_pos - $offset) if verify_context($file_content, \@match_search, $start_pos - $offset);
|
||||
return ($start_pos + $offset) if verify_context($file_content, \@match_search, $start_pos + $offset);
|
||||
}
|
||||
return undef; # Hunk does not apply (context mismatch).
|
||||
}
|
||||
|
||||
# Helper to verify if the hunk's context matches the actual file content at a given index.
|
||||
sub verify_context {
|
||||
my ($lines, $search, $idx) = @_;
|
||||
my $search_size = scalar @$search;
|
||||
return 0 if $idx < 0 || ($idx + $search_size) > scalar @$lines;
|
||||
for (my $i = 0; $i < $search_size; $i++) {
|
||||
my $f_text = $lines->[$idx + $i]; $f_text =~ s/[\r]?$//;
|
||||
my $h_text = substr($search->[$i], 1); $h_text =~ s/[\r]?$//;
|
||||
return 0 if $f_text ne $h_text;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
# --- 8. Dry Run ---
|
||||
# Pre-validation phase: Checks if all hunks can be matched and records offsets.
|
||||
if ($dry_run) {
|
||||
use Cwd 'abs_path';
|
||||
open(my $sf_out, '>', $state_file) or die "$me: Cannot create state file: $!\n";
|
||||
print $sf_out "CKSUM:$patch_hash\n";
|
||||
|
||||
my ($ready_cnt, $fail_cnt, $new_cnt, $del_cnt) = (0, 0, 0, 0);
|
||||
|
||||
foreach my $f (sort keys %patches) {
|
||||
my $abs_target = -e $f ? abs_path($f) : $f;
|
||||
|
||||
if ($patches{$f}{deleted}) {
|
||||
print "DELETE: [Target Removal]\n Path: $abs_target\n\n";
|
||||
$ready_cnt++; $del_cnt++; next;
|
||||
}
|
||||
|
||||
if (-f $f) {
|
||||
my @stats = stat($f);
|
||||
my $file_hash = get_file_fingerprint($f);
|
||||
|
||||
if (exists $state_metadata{$f} && $state_metadata{$f}{hash} ne ""
|
||||
&& $state_metadata{$f}{hash} ne $file_hash) {
|
||||
print "FAIL: [Hash Conflict] (Content changed since state recorded)\n";
|
||||
print " Path: $abs_target\n";
|
||||
my $oct_mode = sprintf("%05o", $stats[$ST_MODE] & 07777);
|
||||
print " Stat: Size:$stats[$ST_SIZE] UID:$stats[$ST_UID] GID:$stats[$ST_GID] Mode:$oct_mode\n\n";
|
||||
$fail_cnt++;
|
||||
next;
|
||||
}
|
||||
|
||||
open(my $fh, '<', $f); my @content = <$fh>; close($fh);
|
||||
|
||||
my (@offsets, $failed, $matched_hunks) = ((), 0, 0);
|
||||
foreach my $h (@{$patches{$f}{hunks}}) {
|
||||
my $idx = find_hunk_index(\@content, $h->{lines}, $h->{old_start} - 1);
|
||||
if (defined $idx) {
|
||||
push @offsets, ($idx - ($h->{old_start} - 1));
|
||||
$matched_hunks++;
|
||||
} else { $failed = 1; }
|
||||
}
|
||||
|
||||
my $total = scalar @{$patches{$f}{hunks}};
|
||||
if ($failed) {
|
||||
print "FAIL: ($matched_hunks/$total hunks matched)\n";
|
||||
$fail_cnt++;
|
||||
} else {
|
||||
# Report if we found it exactly or had to use fuzz
|
||||
my $drift = 0; foreach (@offsets) { $drift = abs($_) if abs($_) > $drift; }
|
||||
my $type = $drift == 0 ? "Exact" : "Fuzzed (drift $drift)";
|
||||
print "READY: [$type] ($matched_hunks/$total hunks)\n";
|
||||
$ready_cnt++;
|
||||
}
|
||||
|
||||
my $oct_mode = sprintf("%05o", $stats[$ST_MODE] & 07777);
|
||||
print " Path: $abs_target\n";
|
||||
print " Stat: Size:$stats[$ST_SIZE] UID:$stats[$ST_UID] GID:$stats[$ST_GID] Mode:$oct_mode\n\n";
|
||||
|
||||
# Save metadata to ensure the file hasn't changed between dry-run and apply.
|
||||
print $sf_out "$f|$stats[$ST_MTIME]|$stats[$ST_SIZE]|" . join(",", @offsets) . "|EXISTING|$file_hash\n" unless $failed;
|
||||
}
|
||||
elsif (!-e $f) {
|
||||
print "CREATE: [New File]\n";
|
||||
print " Path: $abs_target\n";
|
||||
print " Stat: # File does not yet exist\n\n";
|
||||
|
||||
print $sf_out "$f|0|0||NEW|\n";
|
||||
$ready_cnt++; $new_cnt++;
|
||||
}
|
||||
}
|
||||
close($sf_out);
|
||||
|
||||
if ($fail_cnt > 0) {
|
||||
print "VERDICT: FAILED\n";
|
||||
print " $fail_cnt file(s) could not be matched.\n";
|
||||
exit 1;
|
||||
} else {
|
||||
print "VERDICT: SUCCESS\n";
|
||||
print " $ready_cnt file(s) ready to patch.\n";
|
||||
print " $new_cnt file(s) pending creation.\n" if $new_cnt > 0;
|
||||
print " $del_cnt file(s) pending removal.\n" if $del_cnt > 0;
|
||||
exit 0;
|
||||
}
|
||||
}
|
||||
|
||||
# --- 9. Execution ---
|
||||
# Load offsets/hashes generated during the Dry Run to ensure consistent application.
|
||||
my %stabilized_data;
|
||||
if (-e $state_file) {
|
||||
open(my $sf_in, '<', $state_file); <$sf_in>;
|
||||
while (<$sf_in>) {
|
||||
chomp; my ($f, $m, $s, $o, $st, $shash) = split(/\|/);
|
||||
@{$stabilized_data{$f}} = (split(",", $o), $st, $shash);
|
||||
}
|
||||
close($sf_in);
|
||||
}
|
||||
|
||||
my @processed_files;
|
||||
my @deferred_unlinks;
|
||||
|
||||
# Use eval to handle errors gracefully and trigger a rollback if any file fails.
|
||||
eval {
|
||||
foreach my $target (keys %patches) {
|
||||
my $temp_work_file = "${target}.tmp_${temp_suffix}";
|
||||
unlink($temp_work_file) if -e $temp_work_file;
|
||||
my $backup_file = "$target.orig";
|
||||
my $expected_hash = defined $stabilized_data{$target} ? $stabilized_data{$target}[-1] : "";
|
||||
|
||||
# Step A: Safe Backup Sequence
|
||||
# Creates a backup before any modification. rename() is used to ensure atomicity.
|
||||
if (-e $target && !-e $backup_file) {
|
||||
my $current_disk_hash = get_file_fingerprint($target);
|
||||
die "State Conflict: $target drift detected!\n" if $expected_hash ne $current_disk_hash;
|
||||
|
||||
native_copy($target, $temp_work_file);
|
||||
rename($target, $backup_file) or die "Renaming backup failed: $target\n";
|
||||
push @processed_files, $target;
|
||||
rename($temp_work_file, $target) or die "Activating working copy failed: $target\n";
|
||||
|
||||
# Validate that the file hasn't been corrupted during the copy/move process.
|
||||
my $work_hash = get_file_fingerprint($target);
|
||||
die "Integrity Check Failed: $target corruption!\n" if $expected_hash ne $work_hash;
|
||||
|
||||
push @deferred_unlinks, $target if $patches{$target}{deleted};
|
||||
}
|
||||
elsif (!-e $target && !-e $backup_file) {
|
||||
# For new files, create a marker backup so rollback knows to delete them.
|
||||
open(my $marker_fh, '>', $backup_file); close($marker_fh);
|
||||
push @processed_files, $target;
|
||||
}
|
||||
|
||||
next if $patches{$target}{deleted};
|
||||
|
||||
# Step B: Application Logic
|
||||
# Builds the directory structure if it doesn't exist.
|
||||
my $target_dir = dirname($target);
|
||||
if (!-d $target_dir) {
|
||||
my $path_acc = "";
|
||||
foreach my $seg (split(/\//, $target_dir)) {
|
||||
next if $seg eq ""; $path_acc .= "/$seg";
|
||||
mkdir($path_acc, 0755) if !-d $path_acc;
|
||||
}
|
||||
}
|
||||
|
||||
my @file_lines = (-e $target) ? do { open(my $fh, '<', $target); <$fh> } : ();
|
||||
my @hunks = @{$patches{$target}{hunks} // []};
|
||||
# Accessing metadata status/hash relative to the end of the stored list
|
||||
my @offsets = defined $stabilized_data{$target} ? @{$stabilized_data{$target}}[0..$#{$stabilized_data{$target}}-2] : ();
|
||||
my $suppress_final_newline = 0;
|
||||
|
||||
# Apply hunks in reverse order to keep line indices stable for earlier hunks.
|
||||
for (my $i = $#hunks; $i >= 0; $i--) {
|
||||
my $h = $hunks[$i];
|
||||
my $zi_ln = $h->{old_start} - 1;
|
||||
my $match_idx = (@file_lines) ? (defined $offsets[$i] ? ($zi_ln+$offsets[$i]) : find_hunk_index(\@file_lines, $h->{lines}, $zi_ln)) : 0;
|
||||
die "Match failed during apply: $target\n" unless defined $match_idx;
|
||||
|
||||
$suppress_final_newline = 1 if $i == $#hunks && $h->{no_eof_newline};
|
||||
|
||||
my ($removed_count, @transformed) = (0, ());
|
||||
foreach my $line (@{$h->{lines}}) {
|
||||
my $ind = substr($line, 0, 1);
|
||||
my $text = (length($line) > 1) ? substr($line, 1) : "";
|
||||
$text =~ s/\r?[\n]+$//;
|
||||
$text = ($text . "\n");
|
||||
# '-' lines are not added to the list
|
||||
# '+' lines do not increment the removal count
|
||||
if ('-' eq $ind || ' ' eq $ind) {
|
||||
$removed_count++;
|
||||
push @transformed, $text if ' ' eq $ind;
|
||||
}
|
||||
elsif ('+' eq $ind) { push @transformed, $text; }
|
||||
}
|
||||
# Use splice to replace the matched block with the new transformed lines.
|
||||
splice(@file_lines, $match_idx, $removed_count, @transformed);
|
||||
}
|
||||
|
||||
# Step C: Final Atomic Commit
|
||||
# Writes the fully patched result to a temp file, then swaps it with the target.
|
||||
open(my $out_fh, '>', $temp_work_file) or die "Write temp failed: $target\n";
|
||||
for (my $i = 0; $i <= $#file_lines; $i++) {
|
||||
my $l = $file_lines[$i]; $l =~ s/\r?[\n]+$//;
|
||||
print $out_fh ($i == $#file_lines && $suppress_final_newline) ? $l : $l . "\n";
|
||||
}
|
||||
close($out_fh);
|
||||
|
||||
if (-e $backup_file) {
|
||||
my @orig_stat = stat($backup_file);
|
||||
# Apply original UID and GID
|
||||
chown($orig_stat[$ST_UID], $orig_stat[$ST_GID], $temp_work_file)
|
||||
or warn "$me: Warning: Could not preserve ownership for $target: $!\n";
|
||||
|
||||
# Apply original permissions (mode)
|
||||
# Mask with 07777 to keep only the permission and special bits
|
||||
chmod($orig_stat[$ST_MODE] & 07777, $temp_work_file)
|
||||
or warn "$me: Warning: Could not preserve permissions for $target: $!\n";
|
||||
}
|
||||
|
||||
rename($temp_work_file, $target) or die "Commit failed: $target\n";
|
||||
}
|
||||
|
||||
# Clean up files marked for deletion after successful application.
|
||||
foreach my $f_to_del (@deferred_unlinks) {
|
||||
unlink($f_to_del) or warn "$me: Unlink failed: $f_to_del: $!\n";
|
||||
}
|
||||
};
|
||||
|
||||
# Rollback Logic: Restores files to their state before the script began if an error occurred.
|
||||
if ($@) {
|
||||
warn "$me: Application Error: $@. Rolling back changes...\n";
|
||||
foreach my $f (@processed_files) {
|
||||
my $orig = "$f.orig";
|
||||
if (-e $orig) {
|
||||
# Accessing metadata status relative to the end of the stored list
|
||||
my $status = defined $stabilized_data{$f} ? $stabilized_data{$f}[-2] : "EXISTING";
|
||||
if ($status eq "NEW") { unlink($f) if -e $f; unlink($orig); }
|
||||
else { rename($orig, $f); }
|
||||
}
|
||||
}
|
||||
exit 1;
|
||||
}
|
||||
|
||||
unlink($state_file) if -e $state_file;
|
||||
print "Success.\n";
|
||||
|
||||
Reference in New Issue
Block a user