Commit 8274744b92 for openssl.org

commit 8274744b92217ea857baac7b6bac2de4e95bc92d
Author: Bob Beck <beck@openssl.org>
Date:   Wed Aug 12 17:25:46 2026 -0600

    Add a test that generated files are gitignored and untracked

    Every GENERATE target must be absent from the index and covered by a
    committed .gitignore.  The deliberately committed generated files, such
    as util/libcrypto.num and doc/build.info, come from "make update" rather
    than GENERATE, and are not checked.  Skips without git or a checkout.

    Reviewed-by: Andrew Dinh <andrewd@openssl.org>
    Reviewed-by: Nikola Pajkovsky <nikolap@openssl.org>
    MergeDate: Tue Aug 18 15:27:39 2026
    (Merged from https://github.com/openssl/openssl/pull/32343)

diff --git a/test/recipes/01-test_generated_ignored.t b/test/recipes/01-test_generated_ignored.t
new file mode 100644
index 0000000000..09d2cd050e
--- /dev/null
+++ b/test/recipes/01-test_generated_ignored.t
@@ -0,0 +1,205 @@
+#! /usr/bin/env perl
+# -*- mode: Perl -*-
+# Copyright 2026 The OpenSSL Project Authors. All Rights Reserved.
+#
+# Licensed under the Apache License 2.0 (the "License").  You may not use
+# this file except in compliance with the License.  You can obtain a copy
+# in the file LICENSE in the source distribution or at
+# https://www.openssl.org/source/license.html
+
+use strict;
+use warnings;
+
+use File::Spec::Functions qw(catfile splitdir);
+use IPC::Cmd;
+use OpenSSL::Test qw(:DEFAULT bldtop_dir srctop_dir);
+
+use constant MAX_REPORTED => 10;    # Longer lists are truncated
+use constant CANARY => 'configdata.pm';    # Always present, always ignored
+
+BEGIN {
+    setup("test_generated_ignored");
+}
+
+use lib bldtop_dir('.');
+use configdata;
+
+# Every file the build generates must be absent from the index and covered
+# by .gitignore.  The generated files that are deliberately committed --
+# doc/build.info, util/libcrypto.num and the like -- come from explicit
+# "make update" targets rather than GENERATE directives, so they are not
+# among the targets checked here.
+
+plan skip_all => "This test requires git"
+    unless IPC::Cmd::can_run('git');
+# Release tarballs are unpacked archives with no repository.  .git is a
+# directory in an ordinary clone and a file in a linked worktree.
+plan skip_all => "This test requires a git checkout"
+    unless -e catfile(srctop_dir(), '.git');
+# The targets are judged by git's answer to an ignore query, so a query
+# whose answer is known is made first.  configdata.pm is covered by the
+# committed .gitignore, and this recipe has read it out of the build tree
+# already.  The query goes through ignore_faults(), the same path the
+# targets take, and so covers running git, parsing what it prints, and
+# finding the matching rule's source tracked.  Only an ignored verdict for
+# it makes the verdicts on the targets worth anything; short of that the
+# recipe skips.
+my %canary_fault = eval { ignore_faults(CANARY) };
+
+if ($@) {
+    my $reason = $@;
+
+    chomp $reason;
+    plan skip_all => "git cannot answer ignore queries: $reason";
+}
+
+plan skip_all => "git does not report " . CANARY . " as ignored"
+    if %canary_fault;
+
+# The targets are native paths relative to the build tree; git speaks only
+# in forward slashes.  splitdir() knows the local separator, so splitting
+# and rejoining converts them without naming it.
+my @targets = sort map { join('/', splitdir($_)) }
+                   keys %{ $unified_info{generate} };
+
+plan skip_all => "This build configuration generates no files"
+    unless @targets;
+
+plan tests => 2;
+
+note "Checking ", scalar @targets, " generated files";
+
+my %tracked = map { $_ => 1 } git_run('ls-files');
+my %why = ignore_faults(@targets);
+my @committed = grep { $tracked{$_} } @targets;
+my @unignored = grep { exists $why{$_} } @targets;
+
+ok(!@committed, "no generated file is tracked in git")
+    or report_tracked(@committed);
+ok(!@unignored, "every generated file is in .gitignore")
+    or report_unignored(\%why, @unignored);
+
+# Of the given paths, return those .gitignore does not ignore, mapped to an
+# explanation, or to the empty string where none is needed.
+#
+# git draws ignore rules from three places: the .gitignore files in the
+# tree, .git/info/exclude, and the file named by core.excludesFile.  Only
+# the first is committed.  A generated file the other two cover is one that
+# every other clone is free to commit, so it must fail here.  Telling them
+# apart needs the rule that matched, which "git check-ignore -v" reports as
+#
+#     <source>:<line>:<pattern>\t<pathname>
+sub ignore_faults {
+    my (@paths) = @_;
+    my %rule_for;
+
+    foreach my $line (git_batched(['check-ignore', '--no-index', '-v'],
+                                  \@paths)) {
+        # A pattern may contain anything but a tab, so the pathname is
+        # split off from the end.
+        my ($fields, $path) = $line =~ m|^(.*)\t([^\t]*)$|
+            or next;
+        my ($source, $line_no, $pattern) = $fields =~ m|^(.+?):(\d+):(.*)$|
+            or next;
+
+        $rule_for{$path} = { source => $source, line => $line_no,
+                             pattern => $pattern };
+    }
+
+    # An absolute path is core.excludesFile and anything under .git is
+    # .git/info/exclude.  Neither can be tracked, and git rejects them as
+    # pathspecs, so they are dropped before asking.
+    my @sources = grep { !m|^([A-Za-z]:)?[\\/]| && !m|^\.git/| }
+                  do { my %seen;
+                       grep { !$seen{$_}++ } map { $_->{source} }
+                       values %rule_for };
+    my %tracked_source = map { $_ => 1 } git_batched(['ls-files'], \@sources);
+    my %why;
+
+    foreach my $path (@paths) {
+        my $rule = $rule_for{$path};
+
+        # A path is ignored only if some rule matched it, that rule was not
+        # a negative one, and the file holding the rule is committed.
+        next if defined $rule
+                && $rule->{pattern} !~ m|^!|
+                && $tracked_source{ $rule->{source} };
+
+        # Verbose mode reports negative rules too, and those are matches
+        # that leave the path *not* ignored.  They are also the one case
+        # where the placement of the new entry matters, since an entry
+        # above the negative rule has no effect.
+        $why{$path} = defined $rule && $rule->{pattern} =~ m|^!|
+            ? "because $rule->{source} line $rule->{line}"
+              . " ($rule->{pattern}) cancels an earlier match - add it to"
+              . " .gitignore after line $rule->{line}"
+            : '';
+    }
+    return %why;
+}
+
+# Run a git subcommand in the source tree and return its standard output as
+# a list of lines.  Both subcommands used here report their answer entirely
+# on stdout; git's exit status adds only whether that answer was empty,
+# 1 meaning nothing matched, which is an answer and not a failure.  Above
+# that git is declining to answer -- 129 with a usage message when it is
+# too old for the query, 128 for a repository it will not open -- and that
+# dies here rather than being read as an empty answer.
+sub git_run {
+    my (@args) = @_;
+
+    open(my $pipe, '-|', 'git', '-c', 'core.quotePath=false',
+                         '-C', srctop_dir(), @args)
+        or die "Failed to run git @args: $!";
+    my @lines = map { s|\R$||; $_ } <$pipe>;
+    close $pipe;
+    die "git @args killed by signal " . ($? & 127) . "\n" if $? & 127;
+    die "git @args exited with status " . ($? >> 8) . "\n" if ($? >> 8) > 1;
+    return @lines;
+}
+
+# As git_run(), but with a list of paths to act on.  They go in batches:
+# the full list runs to a couple of thousand entries, enough to exceed the
+# command line length limit on some platforms.
+sub git_batched {
+    my ($subcmd, $paths) = @_;
+    my $batch = 100;
+    my @lines;
+
+    for (my $first = 0; $first <= $#$paths; $first += $batch) {
+        my $last = $first + $batch - 1;
+
+        $last = $#$paths if $last > $#$paths;
+        push @lines, git_run(@$subcmd, '--', @{$paths}[$first .. $last]);
+    }
+    return @lines;
+}
+
+sub report_tracked {
+    my (@paths) = @_;
+
+    diag scalar @paths, " generated file(s) are committed to the repository",
+         " and need to be removed:";
+    diag "    git rm --cached ", $_ foreach shown(@paths);
+    diag "    ... and ", @paths - MAX_REPORTED, " more" if @paths > MAX_REPORTED;
+}
+
+# Paths are shown with a leading "/", the anchored form .gitignore uses for
+# generated files.
+sub report_unignored {
+    my ($why, @paths) = @_;
+
+    diag scalar @paths, " generated file(s) are not ignored by .gitignore",
+         " and need to be added:";
+    foreach my $path (shown(@paths)) {
+        diag "    /$path";
+        diag "        ", $why->{$path} if $why->{$path} ne '';
+    }
+    diag "    ... and ", @paths - MAX_REPORTED, " more" if @paths > MAX_REPORTED;
+}
+
+sub shown {
+    my (@paths) = @_;
+
+    return @paths > MAX_REPORTED ? @paths[0 .. MAX_REPORTED - 1] : @paths;
+}