From de536cddc1e68d629bf8c7a7c8a01ec2c54aaf2b Mon Sep 17 00:00:00 2001 From: ruinivist Date: Sun, 22 Mar 2026 17:00:20 +0000 Subject: [PATCH] feat: add path key in config for ro mount of binaries and adding them to PATH env --- internal/config/config.go | 69 ++++++++++++++++++++++++--------- internal/config/config_test.go | 65 ++++++++++++++++++++++--------- internal/config/mire.toml | 3 ++ internal/mire/record.go | 2 +- internal/mire/record_session.go | 8 ++-- internal/mire/record_shell.go | 8 ++-- internal/mire/record_shell.sh | 15 ++++++- internal/mire/record_test.go | 23 +++++++---- internal/mire/test.go | 6 +-- internal/mire/test_test.go | 6 +-- internal/testutil/testutil.go | 4 +- 11 files changed, 148 insertions(+), 61 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index a85aab5..ab51407 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ type Config struct { TestDir string Sandbox map[string]string Mounts []string + Paths []string } type tomlConfig struct { @@ -39,6 +40,7 @@ type tomlMireConfig struct { type tomlSandboxConfig struct { Home string `toml:"home"` Mounts []string `toml:"mounts"` + Paths []string `toml:"paths"` } //go:embed mire.toml @@ -77,8 +79,11 @@ func ReadConfig(path string) (Config, error) { if !meta.IsDefined("sandbox", "mounts") { return Config{}, fmt.Errorf("failed to read %s: missing required sandbox.mounts", path) } + if !meta.IsDefined("sandbox", "paths") { + return Config{}, fmt.Errorf("failed to read %s: missing required sandbox.paths", path) + } - sandbox, mounts, err := decodeSandbox(meta, path, raw.Sandbox) + sandbox, mounts, paths, err := decodeSandbox(meta, path, raw.Sandbox) if err != nil { return Config{}, err } @@ -87,6 +92,7 @@ func ReadConfig(path string) (Config, error) { TestDir: raw.Mire.TestDir, Sandbox: sandbox, Mounts: mounts, + Paths: paths, }, nil } @@ -100,68 +106,71 @@ func WriteDefaultConfig(path string) error { return os.WriteFile(path, body, 0o644) } -func decodeSandbox(meta toml.MetaData, path string, raw toml.Primitive) (map[string]string, []string, error) { +func decodeSandbox(meta toml.MetaData, path string, raw toml.Primitive) (map[string]string, []string, []string, error) { var typed tomlSandboxConfig if err := meta.PrimitiveDecode(raw, &typed); err != nil { - return nil, nil, fmt.Errorf("failed to read %s: %v", path, err) + return nil, nil, nil, fmt.Errorf("failed to read %s: %v", path, err) } var sandboxTable map[string]any if err := meta.PrimitiveDecode(raw, &sandboxTable); err != nil { - return nil, nil, fmt.Errorf("failed to read %s: %v", path, err) + return nil, nil, nil, fmt.Errorf("failed to read %s: %v", path, err) } sandbox := map[string]string{ "home": typed.Home, } for key, value := range sandboxTable { - if key == "home" || key == "mounts" { + if key == "home" || key == "mounts" || key == "paths" { continue } str, ok := value.(string) if !ok { - return nil, nil, fmt.Errorf("failed to read %s: sandbox.%s must be a string", path, key) + return nil, nil, nil, fmt.Errorf("failed to read %s: sandbox.%s must be a string", path, key) } sandbox[key] = str } - return validateSandbox(path, sandbox, typed.Mounts) + return validateSandbox(path, sandbox, typed.Mounts, typed.Paths) } -func validateSandbox(path string, sandbox map[string]string, mounts []string) (map[string]string, []string, error) { +func validateSandbox(path string, sandbox map[string]string, mounts, paths []string) (map[string]string, []string, []string, error) { validated := cloneSandbox(sandbox) for key := range validated { if !lowerSnakeCasePattern.MatchString(key) { - return nil, nil, fmt.Errorf("failed to read %s: invalid sandbox key %q: must be lower_snake_case", path, key) + return nil, nil, nil, fmt.Errorf("failed to read %s: invalid sandbox key %q: must be lower_snake_case", path, key) } } for key := range requiredSandboxDefaults { value, ok := validated[key] if !ok { - return nil, nil, fmt.Errorf("failed to read %s: missing required sandbox.%s", path, key) + return nil, nil, nil, fmt.Errorf("failed to read %s: missing required sandbox.%s", path, key) } if value == "" { - return nil, nil, fmt.Errorf("failed to read %s: empty sandbox.%s", path, key) + return nil, nil, nil, fmt.Errorf("failed to read %s: empty sandbox.%s", path, key) } } if !filepath.IsAbs(validated["home"]) { - return nil, nil, fmt.Errorf("failed to read %s: sandbox.home must be an absolute path", path) + return nil, nil, nil, fmt.Errorf("failed to read %s: sandbox.home must be an absolute path", path) } validatedMounts, err := normalizeMounts(path, mounts) if err != nil { - return nil, nil, err + return nil, nil, nil, err + } + validatedPaths, err := normalizePaths(path, paths) + if err != nil { + return nil, nil, nil, err } - return validated, validatedMounts, nil + return validated, validatedMounts, validatedPaths, nil } func normalizeMounts(configPath string, mounts []string) ([]string, error) { - baseDir := filepath.Dir(configPath) normalized := make([]string, 0, len(mounts)) for _, mount := range mounts { hostPath, sandboxPath, ok := strings.Cut(mount, ":") @@ -169,10 +178,8 @@ func normalizeMounts(configPath string, mounts []string) ([]string, error) { normalized = append(normalized, mount) continue } - if !filepath.IsAbs(hostPath) { - hostPath = filepath.Clean(filepath.Join(baseDir, hostPath)) - } - if _, err := os.Stat(hostPath); err != nil { + hostPath, err := normalizePath(configPath, hostPath) + if err != nil { return nil, fmt.Errorf("failed to read %s: sandbox mount host path %q does not exist", configPath, hostPath) } normalized = append(normalized, hostPath+":"+sandboxPath) @@ -181,6 +188,30 @@ func normalizeMounts(configPath string, mounts []string) ([]string, error) { return normalized, nil } +func normalizePaths(configPath string, paths []string) ([]string, error) { + normalized := make([]string, 0, len(paths)) + for _, path := range paths { + hostPath, err := normalizePath(configPath, path) + if err != nil { + return nil, fmt.Errorf("failed to read %s: sandbox path host path %q does not exist", configPath, hostPath) + } + normalized = append(normalized, hostPath) + } + + return normalized, nil +} + +func normalizePath(configPath, path string) (string, error) { + if !filepath.IsAbs(path) { + path = filepath.Clean(filepath.Join(filepath.Dir(configPath), path)) + } + if _, err := os.Stat(path); err != nil { + return path, err + } + + return path, nil +} + func cloneSandbox(sandbox map[string]string) map[string]string { if len(sandbox) == 0 { return map[string]string{} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index edbdc95..8370124 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -15,13 +15,14 @@ func TestReadConfig(t *testing.T) { wantDir string wantSandbox map[string]string wantMounts []string + wantPaths []string wantErr string wantMissing bool - setup func(t *testing.T, dir string) (string, []string) + setup func(t *testing.T, dir string) (string, []string, []string) }{ { name: "with test dir and sandbox", - setup: func(t *testing.T, dir string) (string, []string) { + setup: func(t *testing.T, dir string) (string, []string, []string) { hostData := filepath.Join(dir, "host-data") hostCache := filepath.Join(dir, "host-cache") for _, path := range []string{hostData, hostCache} { @@ -29,8 +30,9 @@ func TestReadConfig(t *testing.T) { t.Fatalf("MkdirAll(%q) error = %v", path, err) } } - return "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = [\"" + hostData + ":/sandbox/data\", \"" + hostCache + ":/sandbox/cache\"]\nkey_word = \"value\"\n", - []string{hostData + ":/sandbox/data", hostCache + ":/sandbox/cache"} + return "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = [\"" + hostData + ":/sandbox/data\", \"" + hostCache + ":/sandbox/cache\"]\npaths = []\nkey_word = \"value\"\n", + []string{hostData + ":/sandbox/data", hostCache + ":/sandbox/cache"}, + nil }, wantDir: "custom/suite", wantSandbox: map[string]string{ @@ -65,37 +67,37 @@ func TestReadConfig(t *testing.T) { }, { name: "without required home", - content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nmounts = []\n", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nmounts = []\npaths = []\n", wantErr: "missing required sandbox.home", }, { name: "without required mounts", - content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\n", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\npaths = []\n", wantErr: "missing required sandbox.mounts", }, { name: "empty required home", - content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"\"\nmounts = []\n", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"\"\nmounts = []\npaths = []\n", wantErr: "empty sandbox.home", }, { name: "relative home", - content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"home/test\"\nmounts = []\n", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"home/test\"\nmounts = []\npaths = []\n", wantErr: "sandbox.home must be an absolute path", }, { name: "invalid sandbox key", - content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = []\nKeyWord = \"value\"\n", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = []\npaths = []\nKeyWord = \"value\"\n", wantErr: "invalid sandbox key", }, { name: "non string sandbox value", - content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = []\nkey_word = 1\n", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = []\npaths = []\nkey_word = 1\n", wantErr: "sandbox.key_word must be a string", }, { name: "mounts wrong type", - content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = \"oops\"\n", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = \"oops\"\npaths = []\n", wantErr: "failed to read", }, { @@ -109,13 +111,14 @@ func TestReadConfig(t *testing.T) { }, { name: "normalizes relative mount host path", - setup: func(t *testing.T, dir string) (string, []string) { + setup: func(t *testing.T, dir string) (string, []string, []string) { hostBuild := filepath.Join(dir, "build") if err := os.MkdirAll(hostBuild, 0o755); err != nil { t.Fatalf("MkdirAll(%q) error = %v", hostBuild, err) } - return "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = [\"./build:/sandbox/build\"]\n", - []string{hostBuild + ":/sandbox/build"} + return "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = [\"./build:/sandbox/build\"]\npaths = []\n", + []string{hostBuild + ":/sandbox/build"}, + nil }, wantDir: "custom/suite", wantSandbox: map[string]string{ @@ -123,9 +126,23 @@ func TestReadConfig(t *testing.T) { }, }, { - name: "missing mount host path", - content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = [\"./missing:/sandbox/build\"]\n", - wantErr: "sandbox mount host path", + name: "normalizes relative paths host path", + setup: func(t *testing.T, dir string) (string, []string, []string) { + hostTool := filepath.Join(dir, "build", "mend") + if err := os.MkdirAll(filepath.Dir(hostTool), 0o755); err != nil { + t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(hostTool), err) + } + if err := os.WriteFile(hostTool, []byte("tool"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", hostTool, err) + } + return "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = []\npaths = [\"./build/mend\"]\n", + nil, + []string{hostTool} + }, + wantDir: "custom/suite", + wantSandbox: map[string]string{ + "home": "/home/test", + }, }, } @@ -134,9 +151,10 @@ func TestReadConfig(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "mire.toml") wantMounts := tt.wantMounts + wantPaths := tt.wantPaths content := tt.content if tt.setup != nil { - content, wantMounts = tt.setup(t, dir) + content, wantMounts, wantPaths = tt.setup(t, dir) } if !tt.wantMissing { if err := os.WriteFile(path, []byte(content), 0o644); err != nil { @@ -182,6 +200,14 @@ func TestReadConfig(t *testing.T) { t.Fatalf("ReadConfig() Mounts[%d] = %q, want %q", i, got.Mounts[i], want) } } + if len(got.Paths) != len(wantPaths) { + t.Fatalf("ReadConfig() Paths = %#v, want %#v", got.Paths, wantPaths) + } + for i, want := range wantPaths { + if got.Paths[i] != want { + t.Fatalf("ReadConfig() Paths[%d] = %q, want %q", i, got.Paths[i], want) + } + } }) } } @@ -206,4 +232,7 @@ func TestWriteDefaultConfig(t *testing.T) { if len(got.Mounts) != 0 { t.Fatalf("ReadConfig() Mounts = %#v, want empty", got.Mounts) } + if len(got.Paths) != 0 { + t.Fatalf("ReadConfig() Paths = %#v, want empty", got.Paths) + } } diff --git a/internal/config/mire.toml b/internal/config/mire.toml index aea22cc..e95a3e9 100644 --- a/internal/config/mire.toml +++ b/internal/config/mire.toml @@ -8,3 +8,6 @@ # read only paths from host, entry looks like "path on host:path on sanbox" # paths on host are absolute or relative to repo root mounts = [] + # read only host paths to expose on PATH inside the sandbox as /mire/bin/ + # paths on host can be absolute or relative to repo root + paths = [] diff --git a/internal/mire/record.go b/internal/mire/record.go index f4be8f1..a012bdb 100644 --- a/internal/mire/record.go +++ b/internal/mire/record.go @@ -46,7 +46,7 @@ func Record(path string) (string, error) { in: os.Stdin, out: os.Stdout, err: os.Stderr, - }, cfg.Sandbox, cfg.Mounts, setupScripts); err != nil { + }, cfg.Sandbox, cfg.Mounts, cfg.Paths, setupScripts); err != nil { return "", err } diff --git a/internal/mire/record_session.go b/internal/mire/record_session.go index c45bbee..3d933e2 100644 --- a/internal/mire/record_session.go +++ b/internal/mire/record_session.go @@ -18,7 +18,7 @@ type recordIO struct { err io.Writer } -func recordScenario(target, shellPath string, rio recordIO, sandboxConfig map[string]string, mounts, setupScripts []string) error { +func recordScenario(target, shellPath string, rio recordIO, sandboxConfig map[string]string, mounts, paths, setupScripts []string) error { rawIn, rawOut, cleanup, err := newRecordFiles() if err != nil { return err @@ -43,7 +43,7 @@ func recordScenario(target, shellPath string, rio recordIO, sandboxConfig map[st // this error is intentionally discarded to avoid non zero exit status inside record // as an error - runRecordSession(target, rawIn, rawOut, shellPath, sandbox, rio, sandboxConfig, mounts, setupScripts) + runRecordSession(target, rawIn, rawOut, shellPath, sandbox, rio, sandboxConfig, mounts, paths, setupScripts) save, err := confirmRecordSave(rio) if err != nil { @@ -120,7 +120,7 @@ func newRecordSandboxForPathEnv(pathEnv string) (recordSandbox, func(), error) { return sandbox, cleanup, nil } -func runRecordSession(dir, rawIn, rawOut, shellPath string, sandbox recordSandbox, rio recordIO, sandboxConfig map[string]string, mounts, setupScripts []string) error { +func runRecordSession(dir, rawIn, rawOut, shellPath string, sandbox recordSandbox, rio recordIO, sandboxConfig map[string]string, mounts, paths, setupScripts []string) error { rawInFile, err := os.Create(rawIn) if err != nil { return err @@ -135,7 +135,7 @@ func runRecordSession(dir, rawIn, rawOut, shellPath string, sandbox recordSandbo cmd := exec.Command(shellPath) cmd.Dir = dir - cmd.Env = recordSessionEnv(sandbox, sandboxConfig, mounts, setupScripts) + cmd.Env = recordSessionEnv(sandbox, sandboxConfig, mounts, paths, setupScripts) var tty *os.File if file, ok := rio.in.(*os.File); ok { diff --git a/internal/mire/record_shell.go b/internal/mire/record_shell.go index f18b37a..7ca1169 100644 --- a/internal/mire/record_shell.go +++ b/internal/mire/record_shell.go @@ -14,6 +14,7 @@ const recordShellName = "shell.sh" const ( mountsEnvName = "MIRE_MOUNTS" + pathsEnvName = "MIRE_PATHS" compareMarkerEnvName = "MIRE_COMPARE_MARKER" compareMarkerEnabledValue = "1" compareOutputMarker = "__MIRE_PROMPT_READY__" @@ -80,17 +81,18 @@ func buildRecordShellScript() string { return string(body) } -func recordSessionEnv(sandbox recordSandbox, sandboxConfig map[string]string, mounts, setupScripts []string) []string { - return recordSessionEnvWithExtra(sandbox, sandboxConfig, mounts, setupScripts, nil) +func recordSessionEnv(sandbox recordSandbox, sandboxConfig map[string]string, mounts, paths, setupScripts []string) []string { + return recordSessionEnvWithExtra(sandbox, sandboxConfig, mounts, paths, setupScripts, nil) } -func recordSessionEnvWithExtra(sandbox recordSandbox, sandboxConfig map[string]string, mounts, setupScripts []string, extraEnv map[string]string) []string { +func recordSessionEnvWithExtra(sandbox recordSandbox, sandboxConfig map[string]string, mounts, paths, setupScripts []string, extraEnv map[string]string) []string { env := append([]string{}, os.Environ()...) env = append(env, "MIRE_HOST_HOME="+sandbox.hostHome, "MIRE_HOST_TMP="+sandbox.hostTmp, "MIRE_PATH_ENV="+sandbox.pathEnv, mountsEnvName+"="+strings.Join(mounts, "\n"), + pathsEnvName+"="+strings.Join(paths, "\n"), setupScriptsEnvName+"="+strings.Join(setupScripts, "\n"), ) for _, key := range sortedKeys(sandboxConfig) { diff --git a/internal/mire/record_shell.sh b/internal/mire/record_shell.sh index 66ce63b..376a284 100644 --- a/internal/mire/record_shell.sh +++ b/internal/mire/record_shell.sh @@ -8,6 +8,7 @@ visible_home=${MIRE_HOME:?} bootstrap_rc="$host_home/.mire-shell-rc" visible_bootstrap_rc="$visible_home/.mire-shell-rc" setup_scripts_dir='/tmp/mire-setup-scripts' +visible_bin_dir='/mire/bin' cat >"$bootstrap_rc" <<'EOF' cd "${HOME:?}" @@ -37,6 +38,8 @@ EOF set -- \ --ro-bind / / \ --tmpfs /home \ + --tmpfs /mire \ + --dir "$visible_bin_dir" \ --bind "$host_home" "$visible_home" \ --bind "$host_tmp" '/tmp' \ --dev /dev \ @@ -48,7 +51,7 @@ set -- \ --setenv LANG 'C' \ --setenv LC_ALL 'C' \ --setenv PAGER 'cat' \ - --setenv PATH "$path_env" \ + --setenv PATH "$visible_bin_dir:$path_env" \ --setenv PS1 '$ ' \ --setenv TERM 'xterm-256color' \ --setenv TMPDIR '/tmp' \ @@ -78,4 +81,14 @@ ${MIRE_MOUNTS-} EOF fi +if [ -n "${MIRE_PATHS:-}" ]; then + while IFS= read -r host_path || [ -n "$host_path" ]; do + [ -n "$host_path" ] || continue + visible_path=$visible_bin_dir/${host_path##*/} + set -- "$@" --ro-bind "$host_path" "$visible_path" + done <