5 Commits
Author SHA1 Message Date
clawbot dd14677145 README checked against the tree by running every example (closes #22)
check / check (push) Successful in 42s
Every example in the README was run as written with the published test mnemonic and behaved as the README says, so no sentence changed. The only edit removes the landed work from the TODO section, which now lists the two open owner decisions.

Disclosures:
- `ssh install` and `ssh to` were run by the implementer against a throwaway local `sshd`; the reviewer could not repeat that run and checked those sections by reading the code.
- The child mnemonic vector is reachable only through the test suite and was confirmed there.

Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
2026-09-21 18:07:36 +02:00
clawbot ace7846d57 Use gomodguard_v2 in the linter config (closes #31)
check / check (push) Failing after 1s
golangci-lint 2.12 deprecated `gomodguard` in favour of `gomodguard_v2` and printed a warning on every `make check`. `.golangci.yml` now disables the old name, the same way it already handles `wsl` and `wsl_v5`. With `linters.default: all` the replacement was already enabled, so what is checked does not change; only the warning goes.

Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
2026-09-21 17:58:20 +02:00
clawbot 40e9beea8c Clean up the agent socket and working files when a signal ends the tool (closes #17)
check / check (push) Successful in 5s
`cli.Main` ran the command tree on a background context, so SIGINT, SIGTERM or SIGHUP killed the process before deferred cleanup ran: `ssh to` left its agent socket and directory behind, and `ssh install` left a copy of the host's `authorized_keys` in its working directory. `Main` now runs the tree on a `signal.NotifyContext` for those signals; the cancelled context ends the child `ssh` or `sftp` and the cleanup runs. `ssh to` stops its child with SIGTERM, not a kill, so `ssh` restores the terminal. Exit status after a signal is 1 unless `ssh` reported its own.

The test re-runs the test binary as the tool, waits for the agent socket, sends each signal and checks the directory is gone.

Disclosure: the repeated `"uptime"` test literal became a `remoteCommand` constant because `goconst` required it.

Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
2026-09-21 16:58:24 +02:00
clawbot 15ebe24f7b README: policy sections and age and child mnemonic vectors (closes #21)
check / check (push) Successful in 6s
The README gains the sections REPO_POLICIES.md requires: a first sentence naming the category and author, Getting Started, Entrypoints (one line per `script/` file), Rationale, Design, TODO (the open issues between the tree and 1.0), License and Author. It also publishes test vectors for age and child mnemonics, copied from the tests.

Disclosures:
- No license is named; the choice is open on the tracker and the README says so.
- The 12-word child mnemonic is the BIP-85 specification vector, the only one the test asserts, and is labelled as such.
- Markdown is hand-wrapped; `make fmt` here formats Go only.

Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
2026-09-21 16:24:28 +02:00
clawbot 64dcc7f42b Keep the mnemonic out of the ssh and sftp children (closes #16)
check / check (push) Failing after 0s
`keyfunc ssh to` and `keyfunc ssh install` started the system `ssh` and `sftp` with the tool's whole environment, so a mnemonic given in `KEYFUNC_MNEMONIC` stayed readable in the child's environment and could be forwarded to the host by a `SendEnv` line. Both children now get the environment with `KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` removed, through one helper, `childEnv`, in the ssh cli package. The mnemonic command still runs with the full environment. Two tests drive the real commands against the stand-in `ssh` and `sftp` and check that a third variable still arrives.

Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
2026-09-21 14:58:26 +02:00
7 changed files with 261 additions and 24 deletions
+1
View File
@@ -16,6 +16,7 @@ linters:
- depguard # Dependency allow/block lists - depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings: settings:
+17 -13
View File
@@ -1,10 +1,11 @@
# keyfunc # keyfunc
`keyfunc` is an MIT-licensed Go command-line tool by `keyfunc` is a Go command-line tool by [@sneak](https://sneak.berlin) — its
[@sneak](https://sneak.berlin) that turns a BIP-39 mnemonic into SSH keys, age license is not yet chosen
identities and child mnemonics, each of which can be recreated from that ([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)) — that turns a BIP-39
mnemonic at any time. The same mnemonic, key type and index always give the same mnemonic into SSH keys, age identities and child mnemonics, each of which can be
key. recreated from that mnemonic at any time. The same mnemonic, key type and index
always give the same key.
It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and
takes the same steps as that repository's `agehd` package. takes the same steps as that repository's `agehd` package.
@@ -111,6 +112,11 @@ If none of these is available and standard input is not a terminal, the tool
refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is
refused with a message saying so. refused with a message saying so.
`KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the
environment before the system `ssh` (`keyfunc ssh to`) and `sftp`
(`keyfunc ssh install`) are started, so the mnemonic is never handed on to
them.
Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`. Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`.
`keyfunc --version` prints the version. `make build` stamps it; a binary `keyfunc --version` prints the version. `make build` stamps it; a binary
installed with `go install` reports the module version instead. installed with `go install` reports the module version instead.
@@ -198,7 +204,9 @@ Derives the key, serves it from an SSH agent that runs inside the tool on a unix
socket in a new private `0700` temporary directory, then runs the system `ssh` socket in a new private `0700` temporary directory, then runs the system `ssh`
with `-o IdentityAgent=<that socket>` followed by the host and all remaining with `-o IdentityAgent=<that socket>` followed by the host and all remaining
arguments unchanged. The tool exits with `ssh`'s exit status and removes the arguments unchanged. The tool exits with `ssh`'s exit status and removes the
socket and directory on the way out. The private key is never written to disk. socket and directory on the way out. The private key is never written to disk. A
SIGINT, SIGTERM or SIGHUP ends `ssh` and still removes the socket and directory,
and the tool then exits with status 1 unless `ssh` reported one of its own.
## age identities: `keyfunc age` ## age identities: `keyfunc age`
@@ -300,16 +308,12 @@ The open issues that stand between the tree and a 1.0 release:
- [#14 Choose a license and add LICENSE](https://git.eeqj.de/sneak/keyfunc/issues/14) - [#14 Choose a license and add LICENSE](https://git.eeqj.de/sneak/keyfunc/issues/14)
- [#15 Decide the Go module path before 1.0](https://git.eeqj.de/sneak/keyfunc/issues/15) - [#15 Decide the Go module path before 1.0](https://git.eeqj.de/sneak/keyfunc/issues/15)
- [#16 Do not pass the mnemonic environment variables on to ssh and sftp](https://git.eeqj.de/sneak/keyfunc/issues/16)
- [#17 Clean up the agent socket and working files when a signal ends the tool](https://git.eeqj.de/sneak/keyfunc/issues/17)
- [#22 1.0 release readiness](https://git.eeqj.de/sneak/keyfunc/issues/22)
## License ## License
MIT. The license is not yet settled on the tracker Not yet chosen. The license is the owner's decision, still open on the tracker
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)); MIT is the recommended ([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)); the `LICENSE` file is added
option there, so this README names it and the `LICENSE` file is added when that when that issue is answered.
issue is answered.
## Author ## Author
+15 -1
View File
@@ -2,10 +2,13 @@
package cli package cli
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/signal"
"runtime/debug" "runtime/debug"
"syscall"
"git.eeqj.de/sneak/keyfunc/internal/cli/age" "git.eeqj.de/sneak/keyfunc/internal/cli/age"
"git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic" "git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic"
@@ -66,8 +69,19 @@ func Root() *cobra.Command {
// status of its own, which "ssh to" uses to hand on the status ssh // status of its own, which "ssh to" uses to hand on the status ssh
// ended with. ssh has already said whatever it had to say in that // ended with. ssh has already said whatever it had to say in that
// case, so nothing more is printed. // case, so nothing more is printed.
//
// SIGINT, SIGTERM and SIGHUP cancel the command's context instead of
// killing the process outright, so the child ssh or sftp ends and the
// deferred cleanup that removes the agent socket and the install
// working directory still runs.
func Main() int { func Main() int {
err := Root().Execute() ctx, stop := signal.NotifyContext(
context.Background(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
)
defer stop()
err := Root().ExecuteContext(ctx)
if err == nil { if err == nil {
return 0 return 0
} }
+1
View File
@@ -166,6 +166,7 @@ func session(
//nolint:gosec // the options are the user's own, meant for sftp //nolint:gosec // the options are the user's own, meant for sftp
command := exec.CommandContext(cmd.Context(), "sftp", argv...) command := exec.CommandContext(cmd.Context(), "sftp", argv...)
command.Env = childEnv()
command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n") command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n")
command.Stdout = &said command.Stdout = &said
command.Stderr = &said command.Stderr = &said
+23
View File
@@ -3,9 +3,12 @@ package ssh
import ( import (
"fmt" "fmt"
"os"
"strings"
"git.eeqj.de/sneak/keyfunc/internal/cli/options" "git.eeqj.de/sneak/keyfunc/internal/cli/options"
"git.eeqj.de/sneak/keyfunc/internal/derive" "git.eeqj.de/sneak/keyfunc/internal/derive"
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
"git.eeqj.de/sneak/keyfunc/internal/sshkey" "git.eeqj.de/sneak/keyfunc/internal/sshkey"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -84,6 +87,26 @@ func write(cmd *cobra.Command, text string) error {
return nil return nil
} }
// childEnv is the tool's environment with the mnemonic variables taken
// out, for the ssh and sftp children it starts. "ssh to" exists so the
// private key never leaves the tool; the mnemonic, from either variable,
// must not leave it either.
func childEnv() []string {
environ := os.Environ()
kept := make([]string, 0, len(environ))
for _, entry := range environ {
name, _, _ := strings.Cut(entry, "=")
if name == mnemonic.Variable || name == mnemonic.CommandVariable {
continue
}
kept = append(kept, entry)
}
return kept
}
// addComment gives a command its comment flag. // addComment gives a command its comment flag.
func addComment(cmd *cobra.Command) { func addComment(cmd *cobra.Command) {
cmd.Flags().String( cmd.Flags().String(
+9
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"slices" "slices"
"syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -70,10 +71,18 @@ func to() *cobra.Command {
func connect(ctx context.Context, argv []string) error { func connect(ctx context.Context, argv []string) error {
//nolint:gosec // the arguments are the user's own, meant for ssh //nolint:gosec // the arguments are the user's own, meant for ssh
command := exec.CommandContext(ctx, "ssh", argv...) command := exec.CommandContext(ctx, "ssh", argv...)
command.Env = childEnv()
command.Stdin = os.Stdin command.Stdin = os.Stdin
command.Stdout = os.Stdout command.Stdout = os.Stdout
command.Stderr = os.Stderr command.Stderr = os.Stderr
// A cancelled context means a signal ended the tool. Send ssh a
// SIGTERM rather than the default kill, so it puts the terminal
// back the way it found it before it goes.
command.Cancel = func() error {
return command.Process.Signal(syscall.SIGTERM)
}
err := command.Run() err := command.Run()
if err == nil { if err == nil {
return nil return nil
+195 -10
View File
@@ -3,11 +3,14 @@ package cli_test
import ( import (
"bytes" "bytes"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"syscall"
"testing" "testing"
"time"
"git.eeqj.de/sneak/keyfunc/internal/cli" "git.eeqj.de/sneak/keyfunc/internal/cli"
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh" "git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
@@ -15,6 +18,23 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// runAsTool, set in the environment of a re-executed test binary, tells
// TestMain to run the tool through Main rather than the suite, so the
// signal test can drive the real signal path in a process it can send a
// signal to.
const runAsTool = "KEYFUNC_TEST_RUN_AS_TOOL"
// TestMain re-executes the test binary as the tool when runAsTool is
// set, and otherwise runs the suite. The signal test starts the tool
// this way, as a subprocess it can signal and watch clean up.
func TestMain(m *testing.M) {
if os.Getenv(runAsTool) == "1" {
os.Exit(cli.Main())
}
os.Exit(m.Run())
}
// The modes the host is supposed to end up with, and the mode the // The modes the host is supposed to end up with, and the mode the
// stand-ins need so that they can be run at all. // stand-ins need so that they can be run at all.
const ( const (
@@ -47,6 +67,9 @@ const (
keptIn = "authorized_keys" keptIn = "authorized_keys"
) )
// remoteCommand is the command the "to" tests hand ssh after the host.
const remoteCommand = "uptime"
// The tool's own name, as it stands in the arguments a test hands to // The tool's own name, as it stands in the arguments a test hands to
// Main, the ssh subcommand both commands the tests here drive live // Main, the ssh subcommand both commands the tests here drive live
// under, and the one of those two these tests name most. // under, and the one of those two these tests name most.
@@ -60,13 +83,19 @@ const (
// an authorized_keys file. // an authorized_keys file.
const keyLine = vectorZero + " keyfunc/ssh/0\n" const keyLine = vectorZero + " keyfunc/ssh/0\n"
// marker is a variable set beside the mnemonic ones and expected to
// reach the stand-in, so a scrubbed environment is told apart from an
// empty one.
const marker = "KEYFUNC_TEST_MARKER"
// installer is a stand-in for the system sftp for the install // installer is a stand-in for the system sftp for the install
// command. It writes down the arguments and every command of the // command. It writes down the arguments and every command of the
// batch it is given, echoes each command as sftp does, and carries // batch it is given, echoes each command as sftp does, writes down its
// the commands out against a directory standing in for the host's // own environment when a test asks for it, and carries the commands out
// home directory, so that what keyfunc sends can be watched doing its // against a directory standing in for the host's home directory, so that
// work. A command that begins with a dash may fail; any other failure // what keyfunc sends can be watched doing its work. A command that
// ends the session, as it does in sftp's own batch mode. // begins with a dash may fail; any other failure ends the session, as it
// does in sftp's own batch mode.
// //
// The listing and the two ways a get can fail are worded as the // The listing and the two ways a get can fail are worded as the
// OpenSSH client words them, each naming the path the server expanded. // OpenSSH client words them, each naming the path the server expanded.
@@ -81,6 +110,7 @@ const keyLine = vectorZero + " keyfunc/ssh/0\n"
// draws the warning ssh writes for it, which carries the wording of a // draws the warning ssh writes for it, which carries the wording of a
// missing file into a session that goes on to authenticate. // missing file into a session that goes on to authenticate.
const installer = ` const installer = `
[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT"
previous= previous=
for argument in "$@"; do for argument in "$@"; do
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
@@ -144,9 +174,11 @@ done
// caller is a stand-in for the system ssh for the to command. It // caller is a stand-in for the system ssh for the to command. It
// writes down the arguments it was given, notes the agent socket if // writes down the arguments it was given, notes the agent socket if
// there really is one at the path it was handed, and ends with the // there really is one at the path it was handed, writes down its own
// status the test asked for. // environment when a test asks for it, and ends with the status the
// test asked for.
const caller = ` const caller = `
[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT"
for argument in "$@"; do for argument in "$@"; do
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
done done
@@ -157,6 +189,18 @@ fi
exit "$KEYFUNC_TEST_STATUS" exit "$KEYFUNC_TEST_STATUS"
` `
// sleeper is a stand-in for the system ssh that notes the agent socket
// and then blocks, so a test can cancel the context while it is running
// and watch the tool take the agent down. The wait ends on its own only
// as a backstop, well after the test has cancelled and looked.
const sleeper = `
socket=${2#IdentityAgent=}
if [ -S "$socket" ]; then
printf '%s\n' "$socket" > "$KEYFUNC_TEST_SOCKET"
fi
sleep 5
`
// pretended is where a stand-in writes down what it was asked to do. // pretended is where a stand-in writes down what it was asked to do.
type pretended struct { type pretended struct {
// home stands in for the home directory on the host. // home stands in for the home directory on the host.
@@ -412,7 +456,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
arguments, noted := pretendCall(t) arguments, noted := pretendCall(t)
_, err := execute(t, subcommand, "to", host, "uptime") _, err := execute(t, subcommand, "to", host, remoteCommand)
var passed ssh.StatusError var passed ssh.StatusError
@@ -421,7 +465,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
given := recorded(t, arguments) given := recorded(t, arguments)
require.Equal(t, "-o", given[0]) require.Equal(t, "-o", given[0])
require.Equal(t, []string{host, "uptime"}, given[2:]) require.Equal(t, []string{host, remoteCommand}, given[2:])
// The stand-in wrote the path down only because there really was // The stand-in wrote the path down only because there really was
// a socket there while it ran. // a socket there while it ran.
@@ -439,11 +483,130 @@ func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) {
t.Cleanup(func() { os.Args = given }) t.Cleanup(func() { os.Args = given })
os.Args = []string{tool, subcommand, "to", host, "uptime"} os.Args = []string{tool, subcommand, "to", host, remoteCommand}
require.Equal(t, failingStatus, cli.Main()) require.Equal(t, failingStatus, cli.Main())
} }
func TestASignalTakesTheAgentDirectoryDown(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
// The three signals the tool handles, checked one after another.
signals := []struct {
name string
signal os.Signal
}{
{"SIGTERM", syscall.SIGTERM},
{"SIGINT", syscall.SIGINT},
{"SIGHUP", syscall.SIGHUP},
}
for _, ending := range signals {
signalEndsTheTool(t, ending.name, ending.signal)
}
}
// signalEndsTheTool runs the tool as a subprocess against a stand-in
// ssh that blocks, waits until the agent is up and ssh is running
// against it, sends the tool the signal, and requires the agent socket
// and its directory to be gone once the tool has ended. The subprocess
// goes through Main and its signal handling, so with that handling
// removed the signal kills the tool outright, no deferred cleanup runs,
// the directory is left behind, and the check fails.
func signalEndsTheTool(t *testing.T, name string, signal os.Signal) {
t.Helper()
noted := filepath.Join(t.TempDir(), "socket")
t.Setenv("KEYFUNC_TEST_SOCKET", noted)
standIn(t, "ssh", sleeper)
//nolint:gosec // the binary is this test's own, re-run as the tool
command := exec.CommandContext(
t.Context(), os.Args[0], subcommand, "to", host, remoteCommand,
)
command.Env = append(os.Environ(), runAsTool+"=1")
require.NoError(t, command.Start())
// The stand-in notes the socket only once the agent is up and ssh
// is running against it, so this is where the signal lands.
socket := waitForSocket(t, noted)
require.NoError(t, command.Process.Signal(signal))
waitForTool(t, name, command)
// The signal ended the tool, and its deferred cleanup still ran:
// the agent socket and its directory are gone.
require.NoDirExists(t, filepath.Dir(socket), name)
}
// waitForTool waits for the subprocess to end, and fails the test if it
// does not end in time.
func waitForTool(t *testing.T, name string, command *exec.Cmd) {
t.Helper()
done := make(chan error, 1)
go func() { done <- command.Wait() }()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatalf("the tool did not end after %s", name)
}
}
// waitForSocket waits for the stand-in to write down the agent socket
// and gives back the path, which means the agent is up and ssh is
// running against it.
func waitForSocket(t *testing.T, noted string) string {
t.Helper()
var socket string
require.Eventually(t, func() bool {
content, err := os.ReadFile(noted) //nolint:gosec // test path
if err != nil {
return false
}
socket = strings.TrimSpace(string(content))
return socket != ""
}, 5*time.Second, 5*time.Millisecond)
return socket
}
func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "echo "+example())
t.Setenv(mnemonic.Variable, example())
t.Setenv(marker, "reaches the stand-in")
pretendHost(t)
environment := recordEnvironment(t)
install(t, host)
mnemonicWithheld(t, read(t, environment))
}
func TestTheMnemonicIsNotHandedToSSH(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "echo "+example())
t.Setenv(mnemonic.Variable, example())
t.Setenv(marker, "reaches the stand-in")
pretendCall(t)
environment := recordEnvironment(t)
_, err := execute(t, subcommand, "to", host, remoteCommand)
var passed ssh.StatusError
require.ErrorAs(t, err, &passed)
mnemonicWithheld(t, read(t, environment))
}
// pretendHost puts the install stand-in on the path and gives back the // pretendHost puts the install stand-in on the path and gives back the
// places it writes to. // places it writes to.
func pretendHost(t *testing.T) pretended { func pretendHost(t *testing.T) pretended {
@@ -592,6 +755,28 @@ func standIn(t *testing.T, name, body string) {
) )
} }
// recordEnvironment asks the stand-in to write its environment down and
// gives back the file it writes it to.
func recordEnvironment(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "environment")
t.Setenv("KEYFUNC_TEST_ENVIRONMENT", path)
return path
}
// mnemonicWithheld requires that neither mnemonic variable reached the
// stand-in and that the marker set beside them did, so an empty
// environment does not pass for a scrubbed one.
func mnemonicWithheld(t *testing.T, environment string) {
t.Helper()
require.NotContains(t, environment, mnemonic.Variable+"=")
require.NotContains(t, environment, mnemonic.CommandVariable+"=")
require.Contains(t, environment, marker+"=")
}
// read returns what is in a file. // read returns what is in a file.
func read(t *testing.T, path string) string { func read(t *testing.T, path string) string {
t.Helper() t.Helper()