From ec1956ba3762184043a389533f2ccabe036eb0c6 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Thu, 5 Nov 2026 14:40:00 -0600 Subject: [PATCH 1/2] Mirror mise trust into review checkouts Mise wraps Git so it refuses to read an untrusted mise.toml. The review checkout lives under the repository's Git directory, so mise does not inherit the source worktree's trust. Review checkout validation then fails at the first Git command, before Timber can reset the checkout. Copy the source worktree's trust state to the review checkout when the checkout has a mise config. Copy trust in both directions: trust the review checkout when the source is trusted, and untrust it when the source is untrusted, so a review of an untrusted worktree cannot reuse trust that an earlier review left behind. Mirror the trust again after the reset because paranoid mise ties trust to file contents, and a reset can change them. Change-Id: I6f7397be4b751a066ede302c2ace1415ac5edd28 --- internal/timber/review/mise.go | 126 +++++++++++++++++++++ internal/timber/review/mise_test.go | 169 ++++++++++++++++++++++++++++ internal/timber/review/worktree.go | 9 +- 3 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 internal/timber/review/mise.go create mode 100644 internal/timber/review/mise_test.go diff --git a/internal/timber/review/mise.go b/internal/timber/review/mise.go new file mode 100644 index 0000000..cbeeb54 --- /dev/null +++ b/internal/timber/review/mise.go @@ -0,0 +1,126 @@ +package review + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" +) + +// miseConfigFiles names the project configuration files that make mise prompt +// for trust in a checkout root. Trust covers the directory, so one match +// identifies a checkout that needs it. +var miseConfigFiles = []string{"mise.toml", ".mise.toml", "mise.local.toml"} + +// mirrorMiseTrust copies the source checkout's mise trust state to the review +// checkout. Git wrappers installed by mise refuse to read an untrusted project +// config, which breaks every Git command in the review checkout. +// +// Call it before resetting the checkout and again after, because paranoid mise +// ties trust to file contents and a reset can change them. +func mirrorMiseTrust(ctx context.Context, source Repository, path string) error { + if !hasMiseConfig(path) { + return nil + } + trusted, err := miseTrustsDirectory(ctx, source, source.Dir) + if errors.Is(err, exec.ErrNotFound) { + return nil + } + if err != nil { + return err + } + return setReviewMiseTrust(ctx, source, path, trusted) +} + +// setReviewMiseTrust makes the review checkout match the source trust state. +func setReviewMiseTrust(ctx context.Context, source Repository, path string, trusted bool) error { + current, err := miseTrustsDirectory(ctx, source, path) + if err != nil { + return err + } + if current == trusted { + return nil + } + args := []string{"trust", path} + if !trusted { + args = []string{"trust", "--untrust", path} + } + command := exec.CommandContext(ctx, "mise", args...) + // Run from the source checkout so mise does not read the review config + // while it changes the trust of that directory. + command.Dir = source.Dir + command.Env = slices.Clone(source.environment()) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("mirror mise trust in review checkout: %w: %s", err, strings.TrimSpace(string(output))) + } + return nil +} + +func hasMiseConfig(dir string) bool { + for _, name := range miseConfigFiles { + info, err := os.Lstat(filepath.Join(dir, name)) + if err == nil && info.Mode().IsRegular() { + return true + } + } + return false +} + +// miseTrustsDirectory reports whether mise lists dir as trusted. +func miseTrustsDirectory(ctx context.Context, source Repository, dir string) (bool, error) { + command := exec.CommandContext(ctx, "mise", "trust", "--show") + command.Dir = dir + command.Env = slices.Clone(source.environment()) + output, err := command.Output() + if exitError, ok := errors.AsType[*exec.ExitError](err); ok { + return false, fmt.Errorf("inspect mise trust in %s: %w: %s", dir, err, strings.TrimSpace(string(exitError.Stderr))) + } + if err != nil { + return false, fmt.Errorf("inspect mise trust in %s: %w", dir, err) + } + return miseListedAsTrusted(string(output), dir), nil +} + +// miseListedAsTrusted matches a directory against mise trust --show output, +// which names each config directory as trusted or untrusted. +func miseListedAsTrusted(output, dir string) bool { + for line := range strings.SplitSeq(output, "\n") { + name, ok := strings.CutSuffix(strings.TrimSpace(line), ": trusted") + if !ok { + continue + } + if miseSameDirectory(name, dir) { + return true + } + } + return false +} + +// miseSameDirectory compares a reported path, which mise abbreviates under the +// home directory, to an absolute directory. +func miseSameDirectory(reported, dir string) bool { + if rest, ok := strings.CutPrefix(reported, "~/"); ok { + home, err := os.UserHomeDir() + if err != nil { + return false + } + reported = filepath.Join(home, rest) + } + if filepath.Clean(reported) == filepath.Clean(dir) { + return true + } + reportedPath, err := filepath.EvalSymlinks(reported) + if err != nil { + return false + } + dirPath, err := filepath.EvalSymlinks(dir) + if err != nil { + return false + } + return reportedPath == dirPath +} diff --git a/internal/timber/review/mise_test.go b/internal/timber/review/mise_test.go new file mode 100644 index 0000000..347a474 --- /dev/null +++ b/internal/timber/review/mise_test.go @@ -0,0 +1,169 @@ +package review + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type mirrorMiseTrustCase struct { + name string + sourceTrust bool + reviewTrust bool + trustExit string + withoutMise bool + wantArgs string + wantError string +} + +// fakeMise installs a mise stub that records trust state in a file, because +// mise stores trust per directory and the real state is user-global. +func fakeMise(t *testing.T) (logPath, trustedPath string) { + t.Helper() + dir := t.TempDir() + logPath = filepath.Join(dir, "mise.log") + trustedPath = filepath.Join(dir, "trusted") + require.NoError(t, os.WriteFile(trustedPath, nil, 0o600)) + script := `#!/bin/sh +set -eu +printf '%s|%s\n' "$PWD" "$*" >> "$MISE_TEST_LOG" +if [ "$1" = "trust" ] && [ "$2" = "--show" ]; then + if grep -qxF "$PWD" "$MISE_TEST_TRUSTED"; then + printf '%s: trusted\n' "$PWD" + else + printf '%s: untrusted\n' "$PWD" + fi + exit 0 +fi +if [ "$1" = "trust" ] && [ "$2" = "--untrust" ]; then + if [ "${MISE_TEST_TRUST_EXIT:-0}" -ne 0 ]; then + echo "mise untrust refused" >&2 + exit "$MISE_TEST_TRUST_EXIT" + fi + grep -vxF "$3" "$MISE_TEST_TRUSTED" > "$MISE_TEST_TRUSTED.tmp" || true + mv "$MISE_TEST_TRUSTED.tmp" "$MISE_TEST_TRUSTED" + exit 0 +fi +if [ "$1" = "trust" ]; then + if [ "${MISE_TEST_TRUST_EXIT:-0}" -ne 0 ]; then + echo "mise trust refused" >&2 + exit "$MISE_TEST_TRUST_EXIT" + fi + printf '%s\n' "$2" >> "$MISE_TEST_TRUSTED" + exit 0 +fi +exit 64 +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "mise"), []byte(script), 0o700)) + t.Setenv("MISE_TEST_LOG", logPath) + t.Setenv("MISE_TEST_TRUSTED", trustedPath) + t.Setenv("MISE_TEST_TRUST_EXIT", "0") + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return logPath, trustedPath +} + +func writeMiseConfig(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "mise.toml"), []byte("[env]\nTIMBER_TEST = \"1\"\n"), 0o600)) +} + +func readMiseLog(t *testing.T, path string) []string { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + return strings.Split(strings.TrimSpace(string(data)), "\n") +} + +func TestMirrorMiseTrust(t *testing.T) { + for _, test := range []mirrorMiseTrustCase{ + {name: "copies trusted source", sourceTrust: true, wantArgs: "trust"}, + {name: "leaves untrusted source alone"}, + {name: "skips already trusted review", sourceTrust: true, reviewTrust: true}, + {name: "untrusts for an untrusted source", reviewTrust: true, wantArgs: "trust --untrust"}, + {name: "skips already untrusted review"}, + {name: "reports trust failure", sourceTrust: true, trustExit: "1", wantArgs: "trust", wantError: "mirror mise trust in review checkout"}, + {name: "reports untrust failure", reviewTrust: true, trustExit: "1", wantArgs: "trust --untrust", wantError: "mirror mise trust in review checkout"}, + {name: "skips without mise", sourceTrust: true, withoutMise: true}, + } { + t.Run(test.name, func(t *testing.T) { testMirrorMiseTrust(t, test) }) + } +} + +func testMirrorMiseTrust(t *testing.T, test mirrorMiseTrustCase) { + t.Helper() + logPath, trustedPath := fakeMise(t) + source, path := t.TempDir(), t.TempDir() + writeMiseConfig(t, source) + writeMiseConfig(t, path) + var trusted []string + if test.sourceTrust { + trusted = append(trusted, source) + } + if test.reviewTrust { + trusted = append(trusted, path) + } + require.NoError(t, os.WriteFile(trustedPath, []byte(strings.Join(trusted, "\n")+"\n"), 0o600)) + t.Setenv("MISE_TEST_TRUST_EXIT", test.trustExit) + if test.withoutMise { + t.Setenv("PATH", t.TempDir()) + } + repository := Repository{Dir: source, Environment: os.Environ()} + assertMirrorMiseTrust(t, test, mirrorMiseTrust(t.Context(), repository, path), source, path, logPath) +} + +func assertMirrorMiseTrust(t *testing.T, test mirrorMiseTrustCase, err error, source, path, logPath string) { + t.Helper() + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + } else { + require.NoError(t, err) + } + if test.withoutMise { + require.NoFileExists(t, logPath) + return + } + want := []string{source + "|trust --show", path + "|trust --show"} + if test.wantArgs != "" { + want = append(want, source+"|"+test.wantArgs+" "+path) + } + require.Equal(t, want, readMiseLog(t, logPath)) +} + +func TestMirrorMiseTrustSkipsReviewWithoutConfig(t *testing.T) { + logPath, _ := fakeMise(t) + source, path := t.TempDir(), t.TempDir() + writeMiseConfig(t, source) + require.NoError(t, mirrorMiseTrust(t.Context(), Repository{Dir: source, Environment: os.Environ()}, path)) + require.NoFileExists(t, logPath) +} + +func TestMiseListedAsTrustedReadsHomeRelativePath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, "src", "project") + require.True(t, miseListedAsTrusted("~/src/project: trusted\n", dir)) + require.False(t, miseListedAsTrusted("~/src/project: untrusted\n", dir)) + require.False(t, miseListedAsTrusted("/other/project: trusted\n", dir)) + require.False(t, miseListedAsTrusted("mise No trusted config files found.\n", dir)) +} + +func TestPrepareWorktreeMirrorsMiseTrust(t *testing.T) { + _, trustedPath := fakeMise(t) + r := testRepo(t) + writeMiseConfig(t, r.Dir) + gitTest(t, r.Dir, "add", "mise.toml") + gitTest(t, r.Dir, "commit", "-m", "Add mise config") + require.NoError(t, os.WriteFile(trustedPath, []byte(r.Dir+"\n"), 0o600)) + view, err := r.Snapshot(t.Context()) + require.NoError(t, err) + path, err := WorktreePath(r.Dir) + require.NoError(t, err) + require.NoError(t, prepareWorktree(t.Context(), r, path, view.Stacks[0])) + data, err := os.ReadFile(trustedPath) + require.NoError(t, err) + require.Contains(t, strings.Fields(string(data)), path) + require.Equal(t, view.Stacks[0].Tip, gitTest(t, path, "rev-parse", "HEAD")) +} diff --git a/internal/timber/review/worktree.go b/internal/timber/review/worktree.go index 5b67152..353ed46 100644 --- a/internal/timber/review/worktree.go +++ b/internal/timber/review/worktree.go @@ -81,7 +81,14 @@ func prepareWorktree(ctx context.Context, source Repository, path string, stack return err } } - return resetReviewCheckout(ctx, source, path, stack) + if err := mirrorMiseTrust(ctx, source, path); err != nil { + return err + } + if err := resetReviewCheckout(ctx, source, path, stack); err != nil { + return err + } + // A reset can change file contents, and paranoid mise ties trust to them. + return mirrorMiseTrust(ctx, source, path) } func ensureReviewCheckout(ctx context.Context, source Repository, path string, stack Stack) error { From bb58826677254305022b07b075d591cca1fa348d Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Thu, 5 Nov 2026 15:45:16 -0600 Subject: [PATCH 2/2] Fix mise trust tests for the macOS temporary directory The fake mise records the working directory that the shell reports, which is the physical path. macOS gives the temporary directory a symlinked path, so the recorded directories never matched the ones the test built: the trust state looked untrusted and the failure cases reported no error. Resolve the temporary directories in the test, as other review tests do. Change-Id: I0724689f4e2e77b7ee29eeb549518b4243527ca6 --- internal/timber/review/mise_test.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/timber/review/mise_test.go b/internal/timber/review/mise_test.go index 347a474..02468d2 100644 --- a/internal/timber/review/mise_test.go +++ b/internal/timber/review/mise_test.go @@ -65,6 +65,16 @@ exit 64 return logPath, trustedPath } +// resolvedTempDir returns a temporary directory with symlinks resolved. The +// fake mise reports the physical working directory that the shell resolves, and +// macOS makes the temporary directory reachable through a symlink. +func resolvedTempDir(t *testing.T) string { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + return dir +} + func writeMiseConfig(t *testing.T, dir string) { t.Helper() require.NoError(t, os.WriteFile(filepath.Join(dir, "mise.toml"), []byte("[env]\nTIMBER_TEST = \"1\"\n"), 0o600)) @@ -95,7 +105,7 @@ func TestMirrorMiseTrust(t *testing.T) { func testMirrorMiseTrust(t *testing.T, test mirrorMiseTrustCase) { t.Helper() logPath, trustedPath := fakeMise(t) - source, path := t.TempDir(), t.TempDir() + source, path := resolvedTempDir(t), resolvedTempDir(t) writeMiseConfig(t, source) writeMiseConfig(t, path) var trusted []string @@ -134,7 +144,7 @@ func assertMirrorMiseTrust(t *testing.T, test mirrorMiseTrustCase, err error, so func TestMirrorMiseTrustSkipsReviewWithoutConfig(t *testing.T) { logPath, _ := fakeMise(t) - source, path := t.TempDir(), t.TempDir() + source, path := resolvedTempDir(t), resolvedTempDir(t) writeMiseConfig(t, source) require.NoError(t, mirrorMiseTrust(t.Context(), Repository{Dir: source, Environment: os.Environ()}, path)) require.NoFileExists(t, logPath)