#! /usr/bin/perl

# $OpenBSD: update-patches,v 1.24 2023/05/04 14:13:10 espie Exp $
# Copyright (c) 2017-2023
# Marc Espie.  All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Neither the name of OpenBSD nor the names of its contributors
#    may be used to endorse or promote products derived from this software
#    without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY ITS AUTHOR AND THE OpenBSD project ``AS IS'' AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED.  IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
# SUCH DAMAGE.

use File::Find;
use v5.36;

# our "normal" output is STDERR
open my $oldout, '>&STDOUT';
open STDOUT, '>&STDERR'; 

# grab env stuff
my ($distorig, $patchorig, $wrkdist, $patchdir, $patch_list, $wrkobj) = 
    ($ENV{DISTORIG}, $ENV{PATCHORIG}, $ENV{WRKDIST}, $ENV{PATCHDIR},
	 $ENV{PATCH_LIST}, $ENV{WRKOBJDIR});

if ($patchorig ne '.orig.port') {
	say "PATCHORIG=$patchorig";
}
my $force = defined($ENV{FORCE_REGEN});
my $verbose = defined($ENV{PATCH_VERBOSE});

my $origwrkdist = $wrkdist;
# protect against dirty stuff
$wrkdist =~ s/\/$//g;

if (-l $wrkdist) {
	my $d = readlink($wrkdist);
	if (!defined $d) {
		say "WRKDIST=$origwrkdist is a symlink that can't be resolved";
		exit 1;
	}
	if ($d =~ m,^/,) {
		$wrkdist = $d;
	} else {
		require File::Basename;
		$wrkdist = join('/', File::Basename::dirname($wrkdist), $d);
	}
}

if (!-d $wrkdist) {
	if (!-e $wrkdist) {
		say "WRKDIST=$origwrkdist does not exist";
	} else {
		say "WRKDIST=$origwrkdist is not a directory";
	}
	exit 1;
}

my @diff_args;
# XXX more processing maybe ?
if (defined $ENV{DIFF_ARGS}) {
	push(@diff_args, split(/\s+/, $ENV{DIFF_ARGS}));
}

my ($actual, $saved, $done, $nochange);
my @edit;

my $kw_re = qr{\$(
		Author|CVSHeader|Date|Header|Id|Name|Locker|Log|
			RCSFile|Revision|Source|State|OpenBSD
		    )\b.*\$}x;

sub fuzz_chunk($chunk)
{
	return 0 if @{$chunk->{lines}} < 4;
	my $zap = 0;
	my $fuzzed = 0;
	if ($chunk->{lines}[0] =~ m/^\s/ && 
	    $chunk->{lines}[0] =~ m/$kw_re/) {
	    	$zap = 1;
	}
	if ($chunk->{lines}[0] =~ m/^\s/ &&
	    $chunk->{lines}[1] =~ m/^\s/ && 
	    $chunk->{lines}[1] =~ m/$kw_re/) {
	    	$zap = 2;
	}
	while ($zap) {
		shift @{$chunk->{lines}};
		$chunk->{oldstart}++;
		$chunk->{newstart}++;
		$chunk->{oldplus}--;
		$chunk->{newplus}--;
		$zap--;
		$fuzzed = 1;
	}
	if ($chunk->{lines}[-1] =~ m/^\s/ &&
	    $chunk->{lines}[-1] =~ m/$kw_re/) {
	    	$zap=1;
	}
	if ($chunk->{lines}[-1] =~ m/^\s/ &&
	    $chunk->{lines}[-2] =~ m/^\s/ &&
	    $chunk->{lines}[-2] =~ m/$kw_re/) {
	    	$zap=2;
	}
	while ($zap) {
		pop @{$chunk->{lines}};
		$chunk->{oldplus}--;
		$chunk->{newplus}--;
		$zap--;
		$fuzzed = 1;
	}
	return $fuzzed;
}

sub may_fuzz_patch($stem, $list)
{
	my $try_fuzz = 0;
	for my $l (@$list) {
		if ($l =~ m/$kw_re/) {
			$try_fuzz = 1;
			last;
		}
	}

	return unless $try_fuzz;

	my @lines = @$list;
	if (@lines < 2) {
		return;
	}
	# extract the header
	my $h1 = shift @lines;
	my $h2 = shift @lines;
	# cut up the patch
	my $patch = [];
	my $chunk;
	my $fuzzed = 0;

	while (@lines > 0) {
		my $l = shift @lines;
		if ($l =~ m/^\@\@\s+\-(\d+)\,(\d+)\s+\+(\d+)\,(\d+)\s+\@\@$/) {
			if (defined $chunk) {
				if ($chunk->{fuzzable} && fuzz_chunk($chunk)) {
					$fuzzed = 1;
				}
				push(@$patch, $chunk);
			}
			$chunk = {oldstart => $1, oldplus => $2,
				newstart => $3, newplus => $4};
		} else {
			return if !defined $chunk;
			if ($l =~ m/$kw_re/) {
				$chunk->{fuzzable} = 1;
			}
			push(@{$chunk->{lines}}, $l);
		}
	}
	if (defined $chunk) {
		if ($chunk->{fuzzable} && fuzz_chunk($chunk)) {
			$fuzzed = 1;
		}
		push(@$patch, $chunk);
	}

	return unless $fuzzed;

	say "*** Patch for $stem fuzzed because of CVS keywords" if $verbose;
	@$list = ($h1, $h2);

	for my $chunk (@{$patch}) {
		push(@$list, '@@ -'.$chunk->{oldstart}.','.$chunk->{oldplus}.
			' +'.$chunk->{newstart}.','.$chunk->{newplus}.
			' @@'."\n");
		push(@$list, @{$chunk->{lines}});
	}
}

sub create_patch($src, $dst, $stem)
{
	say "Processing $stem" if $verbose;
	open(my $file, "-|", "diff", "-u", "-p", "-a", @diff_args, "-L",
		"$stem.orig", "-L", $stem, "--", $src, $dst) or 
	    die "can't start diff: $!";
	my @lines = <$file>;
	unless (close $file) {
		if ($? != 256) {
			die "diff exited with an error";
		}
	}
	may_fuzz_patch($stem, \@lines);
	return {stem => $stem, patch => \@lines, 
		filename => patch_name($stem),
		comment => [] };
}

sub parse_existing_patch($filename)
{
	open (my $f, '<', $filename) or die "can't read existing $filename: $!";
	my (@comment, $src, @patch);
	while (<$f>) {
		if (m/^Index:\s+(\S.*)/) {
			$src = $1;
			while (<$f>) {
				push(@patch, $_);
			}
			last;
		}
		# XXX have to do *two* matches so that $1 is okay
		# otherwise if $patchorig = 'sthg.orig' this will fail
		if (m/^\-\-\-\s+(\S.*)\Q$patchorig\E/ ||
		    m/^\-\-\-\s+(\S.*)\.orig/) {
			push(@patch, $_);
			$src = $1;
			while (<$f>) {
				push(@patch, $_);
			}
			last;
		}
		push(@comment, $_);
	}
	if (@comment > 0 && $comment[0] =~ m/^\$OpenBSD/) {
		shift @comment;
	}
	while (@comment > 0 && $comment[0] =~ m/^\s*$/) {
		shift @comment;
	}
	return {stem => $src, filename => $filename, 
	    comment => \@comment, patch => \@patch};
}

sub write_patch($p)
{
	if (-f $p->{filename}) {
		rename $p->{filename}, $p->{filename}.".orig" or 
		    die "can't rename $p->{filename}: $!";
	}
	open(my $f, '>', $p->{filename}) or 
	    die "can't write to $p->{filename}: $!";
	for my $l (@{$p->{comment}}) {
		print $f $l;
	}
	if (defined $p->{stem}) {
		print $f "Index: $p->{stem}\n";
	}
	for my $l (@{$p->{patch}}) {
		print $f $l;
	}
	close $f or die;
}

sub patch_name($arg)
{
	$arg =~ s/[\s\/\.]/_/g;
	return "patch-$arg";
}

sub description($p)
{
	if ($p->{filename} ne patch_name($p->{stem})) {
		return "$p->{filename} for $p->{stem}";
	} else {
		return "for $p->{stem}";
	}
}

sub patches_differ($a, $b)
{
	if (@{$a->{patch}} != @{$b->{patch}}) {
		return 1;
	}
	my @m = @{$b->{patch}};
	for my $l (@{$a->{patch}}) {
		my $m = shift @m;
		next if $l =~ m/^(\-\-\-|\+\+\+)\s+\Q$a->{stem}\E/;
		return 1 if $l ne $m;
	}
	return 0;
}

sub identical_msg($name)
{
	return "$name and $name$distorig are identical";
}

# figure out which files to work with
find({wanted =>
    sub() {
    	return if -l $_;
	return unless -f _;
	return unless m/\Q$patchorig\E$/;
	return if $_ eq 'Oops.rej.orig';
	return if m/\Q$distorig\E$/;
	# avoid double reporting patches
	my $src = $File::Find::name;
	my $dst = $src;
	$dst =~ s/\Q$patchorig\E$//;
	# don't double-report
	return if $dst =~ m/^(.*)\.beforesubst$/ && -f $1.$patchorig;
	my $stem = $dst;
	$stem =~ s/^\Q$wrkdist\E\///;
	my $attach = '';
	if (-f "$dst.beforesubst") {
		$dst = "$dst.beforesubst";
		$attach = '.beforesubst';
	} elsif (!-f $dst) {
		say "$stem not found";
		return;
	}
	require File::Compare;
	if (File::Compare::compare($src, $dst) == 0) {
		if ($verbose) {
			say identical_msg($stem);
		} else {
			$nochange->{$stem} = 1;
		}
		return;
	}
	my $p = create_patch($src, $dst, $stem);
	$actual->{$p->{stem}} = $p;
    }, follow => 0, follow_skip => 2 }, $wrkdist);

# do we have patches ?
if (keys %$actual) {
	unless (-d $patchdir) {
		require File::Path;
		File::Path::make_path($patchdir) or die;
	}
}

if (chdir($patchdir)) {
	# figure out which patch is which
	for my $i (glob $patch_list) {
		next unless -f $i;
		next if $i =~ m/(\.orig|\.rej|\~)$/;
		$done->{$i} = 1;
		my $parsed = parse_existing_patch($i);
		if (!defined $parsed->{stem}) {
			say "*** File $i is not a proper patch";
			$parsed->{stem} = $i;
		}
		$saved->{$parsed->{stem}} = $parsed;
	}
}

# handle patches
for my $k (sort keys %$actual) {
	my $p = $actual->{"$k"};
	# is there already a patch ?  we need to compare
	if (exists $saved->{$k}) {
		my $o = $saved->{$k};
		my $differ = patches_differ($o, $p);
		$o->{accounted} = 1;
		next unless $differ || $force;

		$o->{patch} = $p->{patch};
		write_patch($o);
		next unless $differ;

		say "Patch ", description($o), " updated";
		system {"diff"} ('diff', '-u', @diff_args, '--', 
		    $o->{filename}.".orig", $o->{filename}) if $verbose;
		push(@edit, $o->{filename});
	} else {
		say "New patch ", description($p);
		write_patch($p);
		# register it as known so we don't reparse
		$saved->{$p->{stem}} = $p;
		$done->{$p->{filename}} = 1;
		$p->{accounted} = 1;
		push(@edit, $p->{filename});
	}
}

# parse supplementary files
if (chdir($patchdir)) {
	for my $i (glob '*') {
		next unless -f $i;
		next if $i =~ m/(\.orig|\.rej|\~)$/;
		next if $done->{$i};
		my $parsed = parse_existing_patch($i);
		$parsed->{stem} //= $i;
		$saved->{$parsed->{stem}} = $parsed;
	}
}

#for my $k (sort {$a->{filename} cmp $b->{filename}} keys %$old) {
for my $k (sort keys %$saved) {
	my $p = $saved->{"$k"};
	if (!$p->{accounted}) {
		if ($nochange->{$p->{stem}}) {
			say identical_msg($p->{stem});
		}
		say "*** Patch ", description($p), " not accounted for";
	}
	my ($warned_newline, $warned_keyword, $warned_pobj) = (0, 0, 0);
	for my $l (@{$p->{patch}}) {
		if ($l =~ m/^\\ No newline at end of file/) {
			say "*** Patch ", description($p), " misses newline at end of file" 
			    unless $warned_newline;
			$warned_newline = 1;
		} elsif ($l =~ m/$kw_re/) {
			say "*** Patch ",  description($p), " contains CVS keyword" 
			    unless $warned_keyword;
			$warned_keyword = 1;
		} elsif ($l =~ m/\Q$wrkobj\E/) {
			say "*** Patch ", description($p), " contains $wrkobj (WRKOBJDIR)"
			    unless $warned_pobj;
			$warned_pobj = 1;
		}
	}
}

say $oldout join(' ', @edit);
