diff --git a/lib/App/Yath/Command/abort.pm b/lib/App/Yath/Command/abort.pm index bd81b9ebf..f6cd9df4a 100644 --- a/lib/App/Yath/Command/abort.pm +++ b/lib/App/Yath/Command/abort.pm @@ -15,7 +15,7 @@ use Test2::Harness::Runner::State; use Test2::Harness::Util::File::JSON(); use Test2::Harness::Util::Queue(); -use Test2::Harness::Util qw/open_file/; +use Test2::Harness::Util qw/open_file sanitize_filename/; use parent 'App::Yath::Command::status'; use Test2::Harness::Util::HashBase; @@ -52,7 +52,7 @@ sub run { my $running = $state->running_tasks; for my $task (values %$running) { my $pid = $self->get_job_pid($task->{run_id}, $task->{job_id}) // next;; - my $file = $task->{rel_file}; + my $file = sanitize_filename($task->{rel_file}); print "Killing test $pid - $file...\n"; kill('INT', $pid); } diff --git a/lib/App/Yath/Command/failed.pm b/lib/App/Yath/Command/failed.pm index 827b4a2b8..fbc2a47d2 100644 --- a/lib/App/Yath/Command/failed.pm +++ b/lib/App/Yath/Command/failed.pm @@ -7,6 +7,8 @@ our $VERSION = '1.000164'; use Test2::Util::Table qw/table/; use Test2::Harness::Util::File::JSONL; +use Test2::Harness::Util qw/sanitize_filename/; + use parent 'App::Yath::Command'; use Test2::Harness::Util::HashBase qw{ grep { !$seen{$_}++ } sort @{$data->{subtests} // []}; if ($settings->display->brief) { - print $ends->[-1]->{rel_file}, "\n" if $ends->[-1]->{fail}; + print sanitize_filename($ends->[-1]->{rel_file}), "\n" if $ends->[-1]->{fail}; } else { - push @$rows => [$job_id, scalar(@$ends), $ends->[-1]->{rel_file}, $subtests, $ends->[-1]->{fail} ? "NO" : "YES"]; + push @$rows => [$job_id, scalar(@$ends), sanitize_filename($ends->[-1]->{rel_file}), $subtests, $ends->[-1]->{fail} ? "NO" : "YES"]; } } diff --git a/lib/App/Yath/Command/status.pm b/lib/App/Yath/Command/status.pm index 836201e99..a8c162418 100644 --- a/lib/App/Yath/Command/status.pm +++ b/lib/App/Yath/Command/status.pm @@ -6,6 +6,7 @@ our $VERSION = '1.000164'; use Term::Table(); use File::Spec(); +use Test2::Harness::Util qw/sanitize_filename/; use Test2::Harness::Runner::State; use Test2::Harness::Util::File::JSON(); @@ -73,7 +74,7 @@ sub run { next; } - my @rows = map {[$_->{job_id}, $_->{is_try} // $_->{job_try} // 0, $_->{rel_file}, join(', ' => @{$_->{conflicts} // []})]} @tasks; + my @rows = map {[$_->{job_id}, $_->{is_try} // $_->{job_try} // 0, sanitize_filename($_->{rel_file}), join(', ' => @{$_->{conflicts} // []})]} @tasks; my $run_table = Term::Table->new( collapse => 1, header => [qw/uuid try test conflicts/], @@ -117,7 +118,7 @@ sub run { for my $file (keys %{$reload_status->{$stage}}) { next if $seen{$file}++; my $data = $reload_status->{$stage}->{$file} or next; - print "\n==== SOURCE FILE: $file ====\n"; + print "\n==== SOURCE FILE: " . sanitize_filename($file) . " ====\n"; print $data->{error} if $data->{error}; print $_ for @{$data->{warnings} // []}; } @@ -128,7 +129,7 @@ sub run { print "\n**** Running tests: ****\n"; my $running = $state->running_tasks; my $running_tasks = [values %$running]; - my @rows = map {[$self->get_job_pid($_->{run_id}, $_->{job_id}) // 'N/A', $_->{job_id}, $_->{is_try} // $_->{job_try} // 0, $_->{rel_file}, join(', ' => @{$_->{conflicts} // []})]} @$running_tasks; + my @rows = map {[$self->get_job_pid($_->{run_id}, $_->{job_id}) // 'N/A', $_->{job_id}, $_->{is_try} // $_->{job_try} // 0, sanitize_filename($_->{rel_file}), join(', ' => @{$_->{conflicts} // []})]} @$running_tasks; if (@rows) { my $run_table = Term::Table->new( collapse => 1, diff --git a/lib/App/Yath/Command/test.pm b/lib/App/Yath/Command/test.pm index 436ea4b42..93b7166ed 100644 --- a/lib/App/Yath/Command/test.pm +++ b/lib/App/Yath/Command/test.pm @@ -15,7 +15,7 @@ use Test2::Harness::IPC; use Test2::Harness::Runner::State; use Test2::Harness::Util::JSON qw/encode_json decode_json JSON/; -use Test2::Harness::Util qw/mod2file open_file chmod_tmp/; +use Test2::Harness::Util qw/mod2file open_file chmod_tmp sanitize_filename/; use Test2::Util::Table qw/table/; use Test2::Harness::Util::Term qw/USE_ANSI_COLOR/; @@ -450,7 +450,7 @@ sub stop { for my $task (values %$running) { next unless $task->{run_id} && $task->{run_id} eq $self->{+RUN_ID}; my $pid = $self->get_job_pid($task->{run_id}, $task->{job_id}) // next; - my $file = $task->{rel_file}; + my $file = sanitize_filename($task->{rel_file}); print "Killing test $pid - $file...\n"; kill('INT', $pid); } diff --git a/lib/Test2/Formatter/Test2.pm b/lib/Test2/Formatter/Test2.pm index 98787383f..adb44914d 100644 --- a/lib/Test2/Formatter/Test2.pm +++ b/lib/Test2/Formatter/Test2.pm @@ -5,7 +5,7 @@ use warnings; our $VERSION = '1.000164'; use Test2::Util::Term qw/term_size/; -use Test2::Harness::Util qw/hub_truth apply_encoding/; +use Test2::Harness::Util qw/hub_truth apply_encoding sanitize_filename/; use Test2::Harness::Util::Term qw/USE_ANSI_COLOR/; use Test2::Util qw/IS_WIN32 clone_io/; use Time::HiRes qw/time/; @@ -369,7 +369,7 @@ sub update_active_disp { if ($f->{harness_job_launch}) { my $job = $f->{harness_job}; - $self->{+ACTIVE_FILES}->{File::Spec->abs2rel($job->{file})} = $job->{job_name} || $job->{job_id}; + $self->{+ACTIVE_FILES}->{sanitize_filename(File::Spec->abs2rel($job->{file}))} = $job->{job_name} || $job->{job_id}; $should_show = 1; $stats->{running}++; $stats->{todo}--; @@ -378,7 +378,7 @@ sub update_active_disp { if ($f->{harness_job_end}) { my $file = $f->{harness_job_end}->{file}; - delete $self->{+ACTIVE_FILES}->{File::Spec->abs2rel($file)}; + delete $self->{+ACTIVE_FILES}->{sanitize_filename(File::Spec->abs2rel($file))}; $should_show = 1; $stats->{running}--; diff --git a/lib/Test2/Harness/Auditor.pm b/lib/Test2/Harness/Auditor.pm index d624d3e33..b0014c0aa 100644 --- a/lib/Test2/Harness/Auditor.pm +++ b/lib/Test2/Harness/Auditor.pm @@ -9,6 +9,7 @@ use Time::HiRes qw/time/; use Test2::Harness::Util::UUID qw/gen_uuid/; use Test2::Harness::Util::JSON qw/decode_json/; +use Test2::Harness::Util qw/sanitize_filename/; use Test2::Harness::Event; use Test2::Harness::Auditor::Watcher; @@ -94,7 +95,7 @@ sub finish { my $final_data = {pass => 1}; while (my ($job_id, $watchers) = each %{$self->{+WATCHERS}}) { - my $file = File::Spec->abs2rel($self->{+QUEUED}->{$job_id}->{file}); + my $file = sanitize_filename(File::Spec->abs2rel($self->{+QUEUED}->{$job_id}->{file})); if (@$watchers) { push @{$final_data->{failed}} => [$job_id, $file, $watchers->[-1]->failed_subtest_tree] if $watchers->[-1]->fail; diff --git a/lib/Test2/Harness/Renderer/Formatter.pm b/lib/Test2/Harness/Renderer/Formatter.pm index b3dd98b22..a16f621bb 100644 --- a/lib/Test2/Harness/Renderer/Formatter.pm +++ b/lib/Test2/Harness/Renderer/Formatter.pm @@ -10,7 +10,7 @@ use File::Spec; use Storable qw/dclone/; -use Test2::Harness::Util qw/fqmod mod2file/; +use Test2::Harness::Util qw/fqmod mod2file sanitize_filename/; use Test2::Harness::Util::JSON qw/encode_pretty_json/; BEGIN { require Test2::Harness::Renderer; our @ISA = ('Test2::Harness::Renderer') } @@ -106,7 +106,7 @@ sub render_event { tag => $f->{harness_job_launch}->{retry} ? 'RETRY' : 'LAUNCH', debug => 0, important => 1, - details => File::Spec->abs2rel($job->{file}), + details => sanitize_filename(File::Spec->abs2rel($job->{file})), }; } @@ -136,7 +136,7 @@ sub render_event { } if ($self->{+SHOW_JOB_END}) { - my $name = File::Spec->abs2rel($file); + my $name = sanitize_filename(File::Spec->abs2rel($file)); $name .= " - $skip" if $skip; my $tag = 'PASSED'; diff --git a/lib/Test2/Harness/Util.pm b/lib/Test2/Harness/Util.pm index 54bfda142..b5dcd1580 100644 --- a/lib/Test2/Harness/Util.pm +++ b/lib/Test2/Harness/Util.pm @@ -40,8 +40,25 @@ our @EXPORT_OK = qw{ looks_like_uuid is_same_file + + sanitize_filename }; +sub sanitize_filename { + my ($name) = @_; + return $name unless defined $name; + + # Replace ANSI escape sequences (CSI and OSC) with empty string + $name =~ s/\e\[[0-9;]*[A-Za-z]//g; # CSI sequences: ESC [ ... letter + $name =~ s/\e\][^\a\e]*(?:\a|\e\\)//g; # OSC sequences: ESC ] ... BEL/ST + + # Replace remaining control characters (0x00-0x1F, 0x7F) with their + # caret notation, e.g. \x01 => ^A, \x1B => ^[, \x7F => ^? + $name =~ s/([\x00-\x1f\x7f])/'^' . chr(ord($1) ^ 0x40)/ge; + + return $name; +} + sub is_same_file { my ($file1, $file2) = @_; diff --git a/t/unit/Test2/Harness/Util.t b/t/unit/Test2/Harness/Util.t index 1fb12b954..0ea341205 100644 --- a/t/unit/Test2/Harness/Util.t +++ b/t/unit/Test2/Harness/Util.t @@ -88,4 +88,29 @@ ok(is_same_file("$tmp/foo", "$tmp/foo2"), "hard link"); ok(is_same_file("$tmp/foo", "$tmp/foo3"), "soft link"); ok(!is_same_file("$tmp/foo", "$tmp/bar"), "Different files"); +subtest sanitize_filename => sub { + # Normal filename unchanged + is(sanitize_filename('t/foo/bar.t'), 't/foo/bar.t', "Normal filename unchanged"); + + # undef passes through + is(sanitize_filename(undef), undef, "undef passes through"); + + # ANSI CSI sequences stripped (e.g. ESC[0m, ESC[1;31m, ESC[H) + is(sanitize_filename("t/\e[1mBoo\e[0m.t"), 't/Boo.t', "CSI bold/reset stripped"); + is(sanitize_filename("t/\e[H\e[2J.t"), 't/.t', "CSI cursor home + clear stripped"); + is(sanitize_filename("t/\e[1;31mred\e[0m.t"), 't/red.t', "CSI with params stripped"); + + # OSC sequences stripped (ESC ] ... BEL or ESC ] ... ST) + is(sanitize_filename("t/\e]0;evil title\a.t"), 't/.t', "OSC with BEL stripped"); + is(sanitize_filename("t/\e]0;evil title\e\\.t"), 't/.t', "OSC with ST stripped"); + + # Remaining control characters become caret notation + is(sanitize_filename("t/foo\x01bar.t"), 't/foo^Abar.t', "SOH becomes ^A"); + is(sanitize_filename("t/foo\x7fbar.t"), 't/foo^?bar.t', "DEL becomes ^?"); + is(sanitize_filename("t/foo\tbar.t"), 't/foo^Ibar.t', "Tab becomes ^I"); + + # Combined: ANSI stripped first, then control chars escaped + is(sanitize_filename("t/\e[0J\x01.t"), 't/^A.t', "CSI stripped then ctrl escaped"); +}; + done_testing;