#! /usr/bin/env perl

# Simplified BSD License (FreeBSD License)
#
# Copyright (c) 2026, Daily Data Inc.
# 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 source code must retain the above copyright notice, this
#    list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "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 COPYRIGHT HOLDER 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.

# Harness::Runner - orchestrates a run of the step catalogue: reset profiles, config
# mutations (via YAMLPatch, gated by Harness::Containment), actions (via Harness::Safe),
# assertions, restore, and per-Part postconditions/tripwire checks.
#
# See TESTING_automation.md sections 6, 8, 9 for the design this implements.

package Harness::Runner;

use strict;
use warnings;
use Carp qw(croak);
use YAMLPatch;
use Harness::Safe;
use Harness::Containment;

our $VERSION = '0.1.0';

sub new {
    my ($class, %opts) = @_;
    my $self = bless {
        catalogue         => $opts{catalogue}  // croak('Runner: catalogue required'),
        config_path       => $opts{config_path} // '/storage/testing/code/sneakernet/sneakernet.conf.yaml',
        include_optional  => $opts{include_optional} ? 1 : 0,
        email_to          => $opts{email_to},
        baseline_maxdelta => $opts{baseline_maxdelta} // 'commented',
        results           => [],
        vars              => {},
        current_part      => undef,
        production_baseline => undef,
    }, $class;
    # A step that declares config.expects_invalid_config (currently only Step 15.3) has
    # deliberately corrupted the config via the privileged append_raw op (Runner.pm's own
    # mutation dispatch refuses append_raw on any step that doesn't set this flag - see
    # _apply_config_mutations) - the point of that step is proving sneakernet ITSELF refuses to
    # run against a broken config safely, not exercising this harness's own pre-flight gate. A
    # full strict-subset YAMLPatch parse (check_file's only interface) can't tell "deliberately
    # invalid YAML, by design" apart from "a real corruption bug" and dies on either - skip the
    # pre-flight check for that one step only, rather than teaching it to guess.
    $Harness::Safe::CONFIG_CONTAINMENT_CHECK = sub {
        return 1 if $self->{current_step} && $self->{current_step}{config}{expects_invalid_config};
        Harness::Containment::check_file($self->{config_path});
    };
    return $self;
}

# ---------------------------------------------------------------------------
# Variable substitution: runtime-captured values ($S2NAME, $BEFORE, ...), distinct from
# Harness::Catalogue's load-time {TOKEN} expansion. A string containing $NAME is substituted
# from %{$self->{vars}}; a reference to a missing variable is a hard error (this is exactly
# the "empty $S2NAME" defence from TESTING_automation.md 6.4 - a step that reads an unset
# variable fails loudly here, before any primitive sees a degenerate value).
# ---------------------------------------------------------------------------

sub _subst {
    my ($self, $val) = @_;
    if (ref($val) eq 'HASH')  { return { map { $_ => $self->_subst($val->{$_}) } keys %$val }; }
    if (ref($val) eq 'ARRAY') { return [ map { $self->_subst($_) } @$val ]; }
    return $val if ref($val) || !defined($val);
    # (?<!\\) so a backslash-escaped '\$NAME' - e.g. inside a regex pattern like
    # "our \$VERSION = '([\d.]+)'" - is left untouched rather than treated as a
    # runtime-variable reference. Only a bare, unescaped '$NAME' is substituted.
    $val =~ s{(?<!\\)\$(\w+)}{
        my $name = $1;
        croak "Harness::Runner: reference to undefined variable \$$name" unless exists $self->{vars}{$name};
        my $v = $self->{vars}{$name};
        # Under --explain nothing actually executes (see _run_primitive's EXPLAIN branch), so a
        # capture sourced from a readonly primitive is only ever a placeholder ([] -> ''), never
        # real data - the empty-variable guard below exists to stop a genuinely empty runtime
        # value from reaching a destroy/snapshot primitive (TESTING_automation.md 6.4), which
        # cannot happen here since no primitive actually runs. Enforce it everywhere else.
        if ($Harness::Safe::MODE ne 'explain') {
            croak "Harness::Runner: \$$name is defined but empty - refusing to substitute an empty value into a command"
                unless defined($v) && length("$v");
        }
        defined($v) ? "$v" : ''
    }gxe;
    return $val;
}

# ---------------------------------------------------------------------------
# Action dispatch
# ---------------------------------------------------------------------------

my %DISPATCH = (
    zfs_destroy   => sub { my ($s, $a) = @_; Harness::Safe::destroy_dataset(dataset => $a->{dataset}, recursive => $a->{recursive} // 1, missing_ok => $a->{missing_ok} // 1) },
    zfs_create    => sub { my ($s, $a) = @_; Harness::Safe::create_dataset(dataset => $a->{dataset}) },
    zfs_snapshot  => sub { my ($s, $a) = @_; Harness::Safe::snapshot_dataset(dataset => $a->{dataset}) },
    empty_dir     => sub { my ($s, $a) = @_; Harness::Safe::empty_dir(path => $a->{path}) },
    rm_matching   => sub { my ($s, $a) = @_; Harness::Safe::rm_matching(dir => $a->{dir}, basename_re => $a->{pattern}) },
    dir_size_bytes => sub { my ($s, $a) = @_; { output => Harness::Safe::dir_size_bytes(path => $a->{path}) } },
    list_matching => sub { my ($s, $a) = @_; { output => [ Harness::Safe::list_matching(dir => $a->{dir}, basename_re => $a->{pattern}) ] } },
    write_file    => sub { my ($s, $a) = @_; Harness::Safe::write_file(path => $a->{path}, content => $a->{content} // '') },
    write_repeated_line => sub { my ($s, $a) = @_; Harness::Safe::write_repeated_line(path => $a->{path}, line => $a->{line}, count => $a->{count}) },
    copy_file     => sub { my ($s, $a) = @_; Harness::Safe::copy_file(from => $a->{from}, to => $a->{to}) },
    patch_oneshot_updates => sub { my ($s, $a) = @_; Harness::Safe::patch_oneshot_updates(path => $a->{path}, entries => $a->{entries}) },
    corrupt_file  => sub { my ($s, $a) = @_; Harness::Safe::corrupt_file(path => $a->{path}, offset => $a->{offset}, length => $a->{length}) },
    openssl_encrypt_to_file => sub { my ($s, $a) = @_; Harness::Safe::openssl_encrypt_to_file(key => $a->{key}, iv => $a->{iv}, plaintext => $a->{plaintext}, out_path => $a->{out_path}) },
    pipeline_send_to_file   => sub { my ($s, $a) = @_; Harness::Safe::pipeline_zfs_send_to_encrypted_file(snapshot => $a->{snapshot}, key => $a->{key}, iv => $a->{iv}, out_path => $a->{out_path}) },
    run_sneakernet => sub {
        my ($s, $a) = @_;
        my $logPath = $a->{log} ? "/storage/testing/harness/logs/$a->{log}.log" : undef;
        Harness::Safe::run_sneakernet(
            servername => $a->{servername}, verbosity => $a->{verbosity} // 1,
            sn_path => '/storage/testing/code/sneakernet/sneakernet',
            log_path => $logPath, expect_exit => $a->{expect_exit},
        );
    },
    run_sneakernet_info => sub { my ($s, $a) = @_; Harness::Safe::run_sneakernet_info(sn_path => '/storage/testing/code/sneakernet/sneakernet', flag => $a->{flag}) },
    run_perl_c    => sub { my ($s, $a) = @_; Harness::Safe::run_perl_c(path => $a->{path}) },
    run_utility   => sub { my ($s, $a) = @_; Harness::Safe::run_utility(script => $a->{script}, code_dir => '/storage/testing/code', args => $a->{args} // []) },
    svn_update    => sub { my ($s, $a) = @_; Harness::Safe::svn_update(dir => $a->{dir}) },
    svn_status    => sub { my ($s, $a) = @_; { output => [ Harness::Safe::svn_status(dir => $a->{dir}) ] } },
    svnversion    => sub { my ($s, $a) = @_; { output => Harness::Safe::svnversion(dir => $a->{dir}) } },
    dataset_exists=> sub { my ($s, $a) = @_; { output => Harness::Safe::dataset_exists(dataset => $a->{dataset}) } },
    zfs_list_snapshots => sub { my ($s, $a) = @_; { output => [ Harness::Safe::zfs_list_snapshots(dataset => $a->{dataset}) ] } },
    zfs_list_all  => sub { my ($s, $a) = @_; { output => [ Harness::Safe::zfs_list_all_snapshots_under(dataset => $a->{dataset}) ] } },
    random_hex    => sub { my ($s, $a) = @_; { output => Harness::Safe::random_hex(bytes => $a->{bytes}) } },
    now_suffix    => sub { my ($s, $a) = @_; { output => Harness::Safe::now_suffix() } },
    random_bytes  => sub { my ($s, $a) = @_; { output => Harness::Safe::random_bytes(bytes => $a->{bytes}) } },
    read_file     => sub { my ($s, $a) = @_; { output => Harness::Safe::read_file(path => $a->{path}) } },
    read_file_matching => sub { my ($s, $a) = @_; { output => Harness::Safe::read_file_matching(dir => $a->{dir}, basename_re => $a->{pattern}) } },
    # Read-only config lookup (the assertion-side counterpart to config.mutations). Reads
    # directly via YAMLPatch rather than through Harness::Safe, matching how
    # _apply_config_mutations already does - this is a read, not a write/exec, so it doesn't
    # need the sandbox-exec layer, only path() containment, which YAMLPatch's caller (this
    # Runner) already lives inside.
    config_get    => sub {
        my ($s, $a) = @_;
        return { output => undef } if $Harness::Safe::MODE eq 'explain';
        my $y = YAMLPatch->load_file($s->{config_path});
        my $rec = $y->get($a->{path});
        { output => defined($rec) ? $rec->{value} : undef };
    },
    sleep         => sub {
        my ($s, $a) = @_;
        sleep($a->{seconds} // 1) if $Harness::Safe::MODE eq 'run' || $Harness::Safe::MODE eq 'confirm_destructive';
        { output => "slept $a->{seconds}s" };
    },
);

sub _run_action {
    my ($self, $rawAction) = @_;
    my $action = $self->_subst($rawAction);
    my $do = $action->{do} // croak "Harness::Runner: action with no 'do' field";
    my $fn = $DISPATCH{$do} // croak "Harness::Runner: unknown action '$do' - not in the dispatch table";
    my $result = $fn->($self, $action);
    if (my $storeAs = $action->{store_as}) {
        $self->{vars}{$storeAs} = ref($result) eq 'HASH' ? $result->{output} : $result;
    }
    if (my $capture = $action->{capture}) {
        $self->_apply_capture($capture, $result);
    }
    return $result;
}

# capture: { name, select (regex w/ one capture group), reduce ('first'|'count'|'join'), expect_count }
# Operates on a list-shaped primitive result (e.g. zfs_list_snapshots' output). This is the
# structural defence against the empty-$S2NAME class of bug (TESTING_automation.md 6.4): a
# capture that doesn't match expect_count fails the STEP before any primitive sees a
# degenerate value, rather than degrading into 'destroy dataset@' the way an unquoted shell
# variable would.
sub _apply_capture {
    my ($self, $cap, $result) = @_;
    my $out = ref($result) eq 'HASH' ? $result->{output} : $result;
    my @list =
        ref($out) eq 'ARRAY' ? @$out :
        defined($out)        ? split(/\n/, $out) :
                               ();
    my @matches;
    if (my $re = $cap->{select}) {
        for my $line (@list) {
            push @matches, $1 if $line =~ /$re/;
        }
    } else {
        @matches = @list;
    }
    # Under --explain, every primitive returns placeholder/empty data (nothing real ran), so
    # an expect_count mismatch here would be noise, not a finding - skip the check, matching
    # the same relaxation already applied to preconditions/assertions in _run_one_step.
    if ($Harness::Safe::MODE ne 'explain'
        && defined $cap->{expect_count} && scalar(@matches) != $cap->{expect_count}) {
        croak "Harness::Runner: capture '$cap->{name}' expected $cap->{expect_count} match(es), got "
            . scalar(@matches) . " - refusing to proceed (this is what stops an empty capture from"
            . " reaching a destroy/snapshot primitive as a degenerate value)";
    }
    # Default depends on whether 'select' was given: a select implies "extract one specific
    # value" (first match is the sensible default - see ZU_VER/SN_VER-style version captures),
    # while no select implies "I want the whole raw output" for a later multi-line regex_present
    # check - defaulting THAT to 'first' would silently capture only output's first line, which
    # is exactly the class of bug this comment is here to prevent regressing to.
    my $reduce = $cap->{reduce} // ($cap->{select} ? 'first' : 'lines');
    my $value =
        $reduce eq 'count'       ? scalar(@matches) :
        $reduce eq 'join'        ? join(',', @matches) :
        # Order-independent comparison of two captures over the same shape (e.g. a snapshot NAME
        # suffix extracted from two different datasets via 'select') - zfs list's ordering isn't
        # guaranteed to match between a source and a target dataset, so a plain 'join' would make
        # values_equal spuriously fail even when the two sets are identical (see Part 7).
        $reduce eq 'sorted_join' ? join(',', sort @matches) :
        $reduce eq 'lines'       ? join("\n", @matches) :
        $reduce eq 'list'        ? \@matches :
                                   $matches[0];
    $self->{vars}{ $cap->{name} } = $value;
    return $value;
}

# ---------------------------------------------------------------------------
# Assertions
# ---------------------------------------------------------------------------

sub _assert {
    my ($self, $rawAssertion) = @_;
    my $a = $self->_subst($rawAssertion);
    my $type = $a->{type} // croak "Harness::Runner: assertion with no 'type'";
    my $message = $a->{message} // "assertion '$type' failed";

    if ($type eq 'regex_present' || $type eq 'regex_absent') {
        my $text = $self->_text_of($a->{var});
        my $matches = ($text // '') =~ /$a->{pattern}/;
        my $ok = $type eq 'regex_present' ? $matches : !$matches;
        return $self->_record_assert($ok, $message, "var '$a->{var}' " . ($type eq 'regex_present' ? 'lacked' : 'contained') . " /$a->{pattern}/");
    }
    if ($type eq 'count_equals') {
        my $v = $self->{vars}{ $a->{var} };
        my $n = ref($v) eq 'ARRAY' ? scalar(@$v) : $v;
        return $self->_record_assert($n == $a->{n}, $message, "expected count $a->{n}, got $n");
    }
    if ($type eq 'exit_equals') {
        my $v = $self->{vars}{ $a->{var} };
        my $exit = ref($v) eq 'HASH' ? $v->{exit} : $v;
        return $self->_record_assert($exit == $a->{exit}, $message, "expected exit $a->{exit}, got $exit");
    }
    if ($type eq 'values_equal') {
        my ($x, $y) = ($self->{vars}{ $a->{a} }, $self->{vars}{ $a->{b} });
        return $self->_record_assert($x eq $y, $message, "'$a->{a}'=$x vs '$a->{b}'=$y");
    }
    if ($type eq 'values_less_than') {
        my ($x, $y) = ($self->{vars}{ $a->{a} }, $self->{vars}{ $a->{b} });
        return $self->_record_assert($x < $y, $message, "'$a->{a}'=$x not less than '$a->{b}'=$y");
    }
    if ($type eq 'file_nonempty') {
        my $text = Harness::Safe::read_file(path => $a->{path});
        return $self->_record_assert(length($text) > 0, $message, "'$a->{path}' is empty or absent");
    }
    if ($type eq 'files_match') {
        my $x = Harness::Safe::read_file(path => $a->{a});
        my $y = Harness::Safe::read_file(path => $a->{b});
        return $self->_record_assert($x eq $y, $message, "'$a->{a}' and '$a->{b}' differ");
    }
    if ($type eq 'dataset_has_no_children') {
        my @all = Harness::Safe::zfs_list_all_snapshots_under(dataset => $a->{dataset});
        my @children = grep { $_ ne $a->{dataset} && $_ !~ /\@/ } @all;
        return $self->_record_assert(scalar(@children) == 0, $message, "unexpected children: @children");
    }
    if ($type eq 'true') {
        my $v = $self->{vars}{ $a->{var} };
        return $self->_record_assert(!!$v, $message, "'$a->{var}' was false/empty");
    }
    if ($type eq 'raw_file_absent') {
        # Step 13.2: the transport key must not appear in the log, checked WITHOUT redaction,
        # and on failure the report must name the line but never echo it.
        my $text = Harness::Safe::read_file(path => $a->{path});
        my @lines = split /\n/, $text;
        my ($hitLine) = grep { $lines[$_] =~ /\Q$a->{pattern}\E/ } 0 .. $#lines;
        return $self->_record_assert(!defined($hitLine), $message,
            defined($hitLine) ? "pattern found at $a->{path}:" . ($hitLine + 1) . " (content withheld)" : '');
    }
    croak "Harness::Runner: unknown assertion type '$type'";
}

sub _text_of {
    my ($self, $varName) = @_;
    my $v = $self->{vars}{$varName};
    return '' unless defined $v;
    return $v unless ref $v;
    return join("\n", @$v) if ref($v) eq 'ARRAY';
    return $v->{output} // '' if ref($v) eq 'HASH';
    return '';
}

sub _record_assert {
    my ($self, $ok, $message, $detail) = @_;
    push @{ $self->{current_step_assertions} }, { ok => ($ok ? 1 : 0), message => $message, detail => $detail };
    return $ok;
}

# ---------------------------------------------------------------------------
# Reset profiles
# ---------------------------------------------------------------------------

sub _apply_reset {
    my ($self, $profileName) = @_;
    return if $profileName eq 'none';
    my $profile = $self->{catalogue}->reset_profile($profileName)
        // croak "Harness::Runner: unknown reset profile '$profileName'";
    for my $action (@$profile) {
        $self->_run_action($action);
    }
}

# ---------------------------------------------------------------------------
# Config mutations, via YAMLPatch, gated by Harness::Containment
# ---------------------------------------------------------------------------

sub _apply_config_mutations {
    my ($self, $step) = @_;
    my $cfg = $step->{config};
    return unless @{ $cfg->{mutations} } || $cfg->{expects_invalid_config};
    # --explain executes nothing and connects to nothing - not even a read of a config file
    # that, in a from-scratch preview, may not exist on disk yet at all.
    return if $Harness::Safe::MODE eq 'explain';

    my $y = YAMLPatch->load_file($self->{config_path});
    for my $mut (@{ $cfg->{mutations} }) {
        my $m = $self->_subst($mut);
        my $op = $m->{op};
        if    ($op eq 'set')          { $y->set($m->{path}, $m->{value}, style => $m->{style} // 'keep'); }
        elsif ($op eq 'delete')       { $y->delete($m->{path}); }
        elsif ($op eq 'comment_out')  { $y->comment_out($m->{path}); }
        elsif ($op eq 'uncomment')    { $y->uncomment($m->{path}); }
        elsif ($op eq 'insert_kv')    { $y->insert_kv(parent => $m->{parent}, key => $m->{key}, value => $m->{value}, style => $m->{style} // 'bare', (defined $m->{after} ? (after => $m->{after}) : ()), (defined $m->{before} ? (before => $m->{before}) : ())); }
        elsif ($op eq 'insert_block') { $y->insert_block(parent => $m->{parent}, key => $m->{key}, text => $m->{text}, (defined $m->{after} ? (after => $m->{after}) : ()), (defined $m->{before} ? (before => $m->{before}) : ())); }
        elsif ($op eq 'append_raw')   {
            croak "Harness::Runner: append_raw used without config.expects_invalid_config on step '$step->{id}'"
                unless $cfg->{expects_invalid_config};
            $y->append_raw($m->{text}, allow_unsafe => 1);
        }
        else { croak "Harness::Runner: unknown config mutation op '$op'"; }
    }

    if ($Harness::Safe::MODE eq 'explain' || $Harness::Safe::MODE eq 'dry_run') {
        return; # render nothing to disk in these modes - matches Safe's own primitive gating
    }
    $y->save($self->{config_path}, hooks => [ Harness::Containment::as_yamlpatch_hook() ]);
}

# ---------------------------------------------------------------------------
# Per-step execution
# ---------------------------------------------------------------------------

sub run_steps {
    my ($self, @ids) = @_;
    for my $id (@ids) {
        my $step = $self->{catalogue}->step($id) // croak "Harness::Runner: unknown step '$id'";
        $self->_run_one_step($step);
    }
    return $self->{results};
}

sub _run_one_step {
    my ($self, $step) = @_;
    Harness::Safe::set_current_step($step->{id});
    $self->{current_step} = $step;

    if ($step->{classification} eq 'manual') {
        push @{ $self->{results} }, { id => $step->{id}, part => $step->{part}, status => 'N/A', note => $step->{manual_note} };
        return;
    }
    if ($step->{classification} eq 'optional' && !$self->{include_optional}) {
        push @{ $self->{results} }, { id => $step->{id}, part => $step->{part}, status => 'SKIP' };
        return;
    }

    if (!defined($self->{current_part}) || $self->{current_part} != $step->{part}) {
        $self->{current_part} = $step->{part};
        $self->_apply_reset($step->{reset}) if $step->{reset} ne 'none';
        if (defined $self->{production_baseline}) {
            Harness::Safe::assert_production_unchanged($self->{production_baseline});
        }
    }

    $self->{current_step_assertions} = [];
    # In --explain, actions run through Harness::Safe's EXPLAIN verdict (nothing real
    # executes, so every result is a placeholder). Evaluating assertions against placeholder
    # data would produce failures that mean nothing - "the config wasn't actually mutated" is
    # not a real finding under a mode whose entire point is executing nothing. --explain
    # therefore renders the primitives (for the audit log / preview) but never evaluates
    # preconditions or assertions, and reports a distinct 'EXPLAINED' status rather than a
    # potentially-misleading PASS or FAIL.
    my $isExplain = ($Harness::Safe::MODE eq 'explain');
    my $configSnapshotText = $isExplain ? undef : eval { YAMLPatch->load_file($self->{config_path})->as_text };
    my $status = $isExplain ? 'EXPLAINED' : 'PASS';
    my $error;

    eval {
        unless ($isExplain) {
            for my $pre (@{ $step->{config}{preconditions} }) {
                $self->_assert($pre);
            }
        }
        $self->_apply_config_mutations($step);
        for my $action (@{ $step->{actions} }) {
            $self->_run_action($action);
        }
        unless ($isExplain) {
            for my $assertion (@{ $step->{assertions} }) {
                $self->_assert($assertion);
            }
        }
        1;
    } or do {
        $error = $@;
        $status = $isExplain ? 'FAIL' : $status; # a real crash (bad JSON, unknown action, etc) still surfaces even under --explain
    };

    # Restore only makes sense for a step that actually declared config mutations to undo -
    # this must mirror _apply_config_mutations' own "anything to do at all" guard exactly.
    # Steps with no config.mutations at all (e.g. 0.5's write_file, or any plain-actions step)
    # fell through to $step->{config}{restore} being undef, which is "ne 'none'" and so was
    # treated as auto-restore - reverting the step's own config write to whatever the file held
    # immediately before that step ran. For 0.5 that meant writing the canonical baseline config,
    # then immediately reverting to whatever stale content preceded it (found on dd-nas1:
    # mountPoint restored to a corrupted prior value from before an environment teardown).
    my $hasMutations = @{ $step->{config}{mutations} // [] } || $step->{config}{expects_invalid_config};
    my $restore = $hasMutations ? ($step->{config}{restore} // 'auto') : 'none';
    if (!$error && grep { !$_->{ok} } @{ $self->{current_step_assertions} }) {
        $status = $step->{classification} eq 'record_only' ? 'RECORDED' : 'FAIL';
    }
    if ($error) {
        $status = 'FAIL';
    }

    # Must match _apply_config_mutations' own save-skip condition (explain/dry_run) exactly,
    # inverted: mutations are persisted to disk under BOTH 'run' and 'confirm_destructive' (only
    # explain/dry_run skip the save), so restore must revert under both of those too - checking
    # only 'run' here left every mutation from a confirm_destructive run permanently applied,
    # confirmed against dd-nas1: Step 2.3's transport.mountPoint: '' survived into every later
    # step and broke them all, since nothing ever restored it.
    if ($restore ne 'none' && defined($configSnapshotText)
        && ($Harness::Safe::MODE eq 'run' || $Harness::Safe::MODE eq 'confirm_destructive')) {
        eval {
            my $y = YAMLPatch->load_string($configSnapshotText);
            $y->save($self->{config_path}, hooks => [ Harness::Containment::as_yamlpatch_hook() ]);
        };
    }

    for my $cleanupAction (@{ $step->{cleanup} }) {
        eval { $self->_run_action($cleanupAction) };
    }

    push @{ $self->{results} }, {
        id => $step->{id}, part => $step->{part}, status => $status,
        assertions => $self->{current_step_assertions}, error => $error,
    };

    if ($step->{id} eq '4.4' && $status eq 'FAIL') {
        die "Harness::Runner: Step 4.4 failed (leftover scratch dataset check) - halting the "
          . "entire run and leaving state as-is for inspection, per TESTING_automation.md "
          . "section 10's one exception to continue-on-fail.\n";
    }
}

sub results { return @{ $_[0]->{results} } }
sub set_production_baseline { my ($self, $b) = @_; $self->{production_baseline} = $b; }

1;

__END__
See sneakernet/Documentation/TESTING_automation.md sections 6, 8, and 9. Harness-internal,
like Harness::Safe and Harness::Containment.
