diff --git a/Osstest/TestSupport.pm b/Osstest/TestSupport.pm
index 135289b..ba55967 100644
--- a/Osstest/TestSupport.pm
+++ b/Osstest/TestSupport.pm
@@ -55,7 +55,7 @@ BEGIN {
 
                       target_cmd_root_status target_cmd_output_root_status
                       target_cmd_root target_cmd target_cmd_build
-                      target_cmd_stashed
+                      target_cmd_stashed target_subunit_cmd
                       target_cmd_output_root target_cmd_output
                       target_cmd_inputfh_root sshuho
                       target_getfile target_getfile_root
@@ -775,6 +775,121 @@ sub target_cmd_stashed ($$$;$$) {
     return "$stash/$$leafref";
 }
 
+sub subunit_result_to_osstest_result ($) {
+    my ($ret) = @_;
+    return "pass" if $ret eq "success" or $ret eq "successful";
+    return "fail" if $ret eq "failure";
+    return "skip" if $ret eq "skip";
+    return "fail" if $ret eq "error";
+    # expected failure
+    return "pass" if $ret eq "xfail";
+    # unexpected success
+    return "fail" if $ret eq "uxsuccess";
+    die "subunit_result_to_osstest_result unexpected result $ret";
+}
+sub subunit_sanitize_testname ($) {
+    my ($testname) = @_;
+    $testname =~ s'[^_.()\[\]/~0-9a-zA-Z-]'_'g;
+    return $testname;
+}
+
+# Like target_cmd, but parse the command output as a subunit v1 stream and make
+# a substep out of each subunit test.
+sub target_subunit_cmd ($$;$$) {
+    my ($tho,$tcmd,$timeout,$extrasshopts) = @_;
+    my $filename = "subunit-output";
+    my $path = target_cmd_stashed($tho, \$filename, $tcmd, $timeout,
+        $extrasshopts);
+
+    open my $stdout, "$path" or die "$path: $!";
+
+    my $logfilename = undef;
+    my $fh = undef;
+
+    while (<$stdout>) {
+        if (/^time: (\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+)(\.\d+)?Z$/) {
+            # This is the timestamp for the next events
+        } elsif (/^test(?:ing)?:? (.+)\n/) {
+            # Start of a new test.
+            $logfilename = subunit_sanitize_testname($1) . '.log';
+            $fh = open_unique_stashfile(\$logfilename);
+            substep_start(subunit_sanitize_testname($1), $logfilename);
+        } elsif (/^(success(?:ful)?|failure|skip|error|xfail|uxsuccess):
+                   \ (.+?)(\ \[(\ multipart)?)?$/x) {
+            # Result of a test, with its output.
+            my $result = $1;
+            my $testname = $2;
+            my $have_details = $3;
+            my $is_multipart = $4;
+
+            if ($have_details) {
+                if ($is_multipart) {
+                    # Test output
+                    while (<$stdout>) {
+                        # part content-type
+                        # from https://tools.ietf.org/html/rfc6838#section-4.2
+                        my $restricted_name = qr'[a-zA-Z0-9][a-zA-Z0-9!#$&^_.+-]*';
+                        if (m{ ^Content-Type:\s+
+                                $restricted_name/$restricted_name # type/sub-type
+                                # parameters
+                                (?:\s*;\s*$restricted_name=[^,]+
+                                  (?:,\s*$restricted_name=[^,]+)*)
+                                \s*$
+                            }xi) {
+                            print $fh $_ or die $!;
+
+                            # part name
+                            my $line = <$stdout>;
+                            print $fh $line or die $!;
+
+                            # Read chunks of a part
+                            while (<$stdout>) {
+                                if (/^([0-9A-F]+)\r$/i) {
+                                    my $chunk_size = hex($1);
+                                    my $chunk;
+
+                                    last if $chunk_size == 0;
+                                    read $stdout, $chunk, $chunk_size;
+                                    print $fh $chunk or die $!;
+                                } else {
+                                    # Unexpected output
+                                    chomp;
+                                    logm("*** $_");
+                                }
+                            }
+                        } elsif (/^\]$/) {
+                            last;
+                        } else {
+                            # Unexpected output
+                            chomp;
+                            logm("*** $_");
+                        }
+                    }
+                } else {
+                    # Simple non-multipart test output.
+                    while (<$stdout>) {
+                        last if (/^\]$/);
+                        print $fh $_ or die $!;
+                    }
+                }
+            }
+            close $fh or die $!;
+            substep_finish(subunit_sanitize_testname($testname),
+                subunit_result_to_osstest_result($result));
+        } elsif (/^tags: .+/) {
+            # unused
+        } elsif (/^progress: (?:[+-]?\d+|push|pop)$/) {
+            # unused
+        } else {
+            # Unexpected output
+            chomp;
+            logm("*** $_");
+        }
+    }
+
+    close $stdout or die $!;
+}
+
 sub poll_loop ($$$&) {
     my ($maxwait, $interval, $what, $code) = @_;
     # $code should return undef when all is well
