diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 64563a44..4b7f78dd 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -112,6 +112,10 @@ Blocked features are rejected before execution with exit code 2. - ❌ No automatic inheritance from the host process - ❌ `export`, `readonly` are blocked +## Intentional Divergences from Bash + +- **Time reference for `find -mmin`/`-mtime` and `ls -l`**: rshell captures `time.Now()` once at the start of each `Run()` call and shares it across all builtins in that run. Bash evaluates each command against its own invocation time. In practice this only matters for long-running scripts (e.g. `sleep 61; find . -mmin -1`) where the reference time drifts from the actual command start. Short-lived AI agent scripts are unaffected. + ## Appendix -Formating: In each category, supported features should be listed first, and the most useful ones first. +Formatting: In each category, supported features should be listed first, and the most useful ones first. diff --git a/allowedsymbols/symbols_interp.go b/allowedsymbols/symbols_interp.go index 7b75f516..ce72c6c9 100644 --- a/allowedsymbols/symbols_interp.go +++ b/allowedsymbols/symbols_interp.go @@ -59,6 +59,7 @@ var interpAllowedSymbols = []string{ "sync.Once", // ensures a function runs exactly once; concurrency primitive, no I/O. "sync.WaitGroup", // waits for goroutines to finish; concurrency primitive, no I/O. "time.Now", // returns current time; read-only, no mutation. + "time.Time", // time value type; pure data, no side effects. // --- mvdan.cc/sh/v3/expand --- (shell word expansion library) diff --git a/builtins/builtins.go b/builtins/builtins.go index 95e5e17a..cdeed233 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -121,10 +121,22 @@ type CallContext struct { // PortableErr normalizes an OS error to a POSIX-style message. PortableErr func(err error) string - // Now returns the current time. Builtins should use this instead of - // calling time.Now() directly, so the time source is consistent and - // testable. - Now func() time.Time + // Now is the time captured at the start of each Run() call. Builtins + // should use this instead of calling time.Now() directly, so the time + // source is consistent across all commands in a single run. + // + // Note: this means all builtins within one Run() share the same reference + // time, whereas bash evaluates each command against its own invocation + // time. This is an intentional trade-off for consistency within a script + // run. + // + // Run() always sets this before dispatching any builtin; Reset() clears + // it, so it is always re-set by the next Run() call. The zero value + // (time.Time{}) is reserved as the unset sentinel; callers constructing + // CallContext directly (e.g. in tests) must set this to a non-zero value + // before invoking builtins that use time predicates (find -mmin/-mtime, + // ls -l). + Now time.Time // FileIdentity extracts canonical file identity from FileInfo. // On Unix: dev+inode from Stat_t. On Windows: volume serial + file index diff --git a/builtins/find/find.go b/builtins/find/find.go index e909a8a4..bc6084fe 100644 --- a/builtins/find/find.go +++ b/builtins/find/find.go @@ -207,9 +207,7 @@ optLoop: } } - // Capture invocation time once so -mtime/-mmin predicates use a - // consistent reference across all root paths (matches GNU find). - now := callCtx.Now() + now := callCtx.Now // GNU find treats a missing -newer reference as a fatal argument error // and produces no result set, so skip the walk entirely. diff --git a/builtins/find/now_test.go b/builtins/find/now_test.go index a1bdedf0..51533a63 100644 --- a/builtins/find/now_test.go +++ b/builtins/find/now_test.go @@ -11,7 +11,6 @@ import ( "io/fs" "os" "path/filepath" - "sync/atomic" "testing" "time" @@ -21,11 +20,13 @@ import ( "github.com/DataDog/rshell/builtins" ) -// TestNowCalledOnce verifies that find captures the invocation timestamp -// once in run(), not per root path. GNU find evaluates -mtime/-mmin -// relative to a single invocation time, so multi-path invocations must -// use a consistent reference. -func TestNowCalledOnce(t *testing.T) { +// TestNowConsistentAcrossRoots verifies that find with multiple root paths +// finds files in all roots when using a shared time reference from +// CallContext.Now. The single-capture invariant is now structurally enforced +// by the time.Time value field (there is no function to call multiple times), +// so this test acts as a regression guard for multi-root traversal with a +// time predicate. +func TestNowConsistentAcrossRoots(t *testing.T) { // Create two directories with one file each. tmp := t.TempDir() dir1 := filepath.Join(tmp, "a") @@ -35,17 +36,11 @@ func TestNowCalledOnce(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir1, "f1.txt"), []byte("x"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(dir2, "f2.txt"), []byte("y"), 0644)) - var nowCalls atomic.Int32 - fixedNow := time.Now() - var stdout, stderr bytes.Buffer callCtx := &builtins.CallContext{ Stdout: &stdout, Stderr: &stderr, - Now: func() time.Time { - nowCalls.Add(1) - return fixedNow - }, + Now: time.Now(), LstatFile: func(_ context.Context, path string) (fs.FileInfo, error) { return os.Lstat(filepath.Join(tmp, path)) }, @@ -71,8 +66,63 @@ func TestNowCalledOnce(t *testing.T) { result := run(context.Background(), callCtx, []string{"a", "b", "-mmin", "-60"}) assert.Equal(t, uint8(0), result.Code, "find should succeed") - assert.Equal(t, int32(1), nowCalls.Load(), - "Now() should be called exactly once per find invocation, not per root path") assert.Contains(t, stdout.String(), "f1.txt") assert.Contains(t, stdout.String(), "f2.txt") } + +// TestNowFromCallContextIsUsed verifies that find actually uses the Now value +// from CallContext for predicate evaluation. A fixed timestamp far in the +// future is supplied; files created right now should appear very old relative +// to that future Now, so they should match +1 (older than 1 minute) and not +// match -1 (newer than 1 minute). +func TestNowFromCallContextIsUsed(t *testing.T) { + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "fresh.txt"), []byte("x"), 0644)) + + var stdout, stderr bytes.Buffer + // Use a timestamp 10 years in the future. From that reference point the + // fresh file was created "10 years ago", so diff = futureNow - mtime ≈ 10yr. + // It should match +1 (older than 1 minute) but not -1 (newer than 1 minute). + futureNow := time.Now().Add(10 * 365 * 24 * time.Hour) + callCtx := &builtins.CallContext{ + Stdout: &stdout, + Stderr: &stderr, + Now: futureNow, + LstatFile: func(_ context.Context, path string) (fs.FileInfo, error) { + return os.Lstat(filepath.Join(tmp, path)) + }, + StatFile: func(_ context.Context, path string) (fs.FileInfo, error) { + return os.Stat(filepath.Join(tmp, path)) + }, + OpenDir: func(_ context.Context, path string) (fs.ReadDirFile, error) { + return os.Open(filepath.Join(tmp, path)) + }, + IsDirEmpty: func(_ context.Context, path string) (bool, error) { + entries, err := os.ReadDir(filepath.Join(tmp, path)) + if err != nil { + return false, err + } + return len(entries) == 0, nil + }, + PortableErr: func(err error) string { + return err.Error() + }, + } + + // A fresh file should match +1 (older than 1 minute) when Now is 10 years + // in the future, proving that CallContext.Now is used for evaluation. + result := run(context.Background(), callCtx, []string{".", "-name", "fresh.txt", "-mmin", "+1"}) + assert.Equal(t, uint8(0), result.Code, "find should succeed") + assert.Contains(t, stdout.String(), "fresh.txt", + "fresh file should match -mmin +1 when CallContext.Now is 10 years in the future") + assert.Empty(t, stderr.String()) + + // The same file should NOT match -1 (newer than 1 minute) under the same Now. + stdout.Reset() + stderr.Reset() + result = run(context.Background(), callCtx, []string{".", "-name", "fresh.txt", "-mmin", "-1"}) + assert.Equal(t, uint8(0), result.Code, "find should succeed") + assert.NotContains(t, stdout.String(), "fresh.txt", + "fresh file should not match -mmin -1 when CallContext.Now is 10 years in the future") + assert.Empty(t, stderr.String()) +} diff --git a/builtins/ls/ls.go b/builtins/ls/ls.go index d1e0cac2..27c8509f 100644 --- a/builtins/ls/ls.go +++ b/builtins/ls/ls.go @@ -137,7 +137,7 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { return builtins.Result{} } - now := callCtx.Now() + now := callCtx.Now // Determine the effective sort mode. When both -S and -t are given, // the last one specified wins, matching GNU ls behaviour. diff --git a/builtins/tests/cat/cat_differential_fuzz_test.go b/builtins/tests/cat/cat_differential_fuzz_test.go index 39c753a7..23f2d7b4 100644 --- a/builtins/tests/cat/cat_differential_fuzz_test.go +++ b/builtins/tests/cat/cat_differential_fuzz_test.go @@ -86,13 +86,17 @@ func FuzzCatDifferential(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + // Use context.Background() (not t.Context()) so the fuzz engine's + // cancellation does not kill the command mid-run; each iteration still + // enforces its own 5 s deadline. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel rshellOut, rshellErr, rshellCode := cmdRunCtx(ctx, t, "cat input.txt", dir) + cancel() - // If the fuzz engine cancelled us (fuzztime expired), bail out - // without comparing — partial output would cause false failures. + // If the fuzz engine's budget expired (t.Context(), not the per-command + // context above), bail out without comparing — partial output would cause + // false failures. if t.Context().Err() != nil { return } diff --git a/builtins/tests/cat/cat_fuzz_test.go b/builtins/tests/cat/cat_fuzz_test.go index 9a5bf2c4..fdb75219 100644 --- a/builtins/tests/cat/cat_fuzz_test.go +++ b/builtins/tests/cat/cat_fuzz_test.go @@ -75,10 +75,10 @@ func FuzzCat(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel stdout, _, code := cmdRunCtx(ctx, t, "cat input.txt", dir) + cancel() if code != 0 && code != 1 { t.Errorf("unexpected exit code %d", code) } @@ -126,10 +126,10 @@ func FuzzCatNumberLines(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "cat -n input.txt", dir) + cancel() if code != 0 && code != 1 { t.Errorf("cat -n unexpected exit code %d", code) } @@ -188,10 +188,10 @@ func FuzzCatDisplayFlags(f *testing.F) { flags += " -T" } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "cat"+flags+" input.bin", dir) + cancel() if code != 0 && code != 1 { t.Errorf("cat%s unexpected exit code %d", flags, code) } @@ -226,10 +226,10 @@ func FuzzCatStdin(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel stdout, _, code := cmdRunCtx(ctx, t, "cat < stdin.txt", dir) + cancel() if code != 0 && code != 1 { t.Errorf("cat stdin unexpected exit code %d", code) } diff --git a/builtins/tests/cut/cut_fuzz_test.go b/builtins/tests/cut/cut_fuzz_test.go index 9e2b4b5c..6b7e1220 100644 --- a/builtins/tests/cut/cut_fuzz_test.go +++ b/builtins/tests/cut/cut_fuzz_test.go @@ -97,10 +97,10 @@ func FuzzCutFields(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtxFuzz(ctx, t, fmt.Sprintf("cut -f %s input.txt", fieldSpec), dir) + cancel() if code != 0 && code != 1 { t.Errorf("cut -f %s unexpected exit code %d", fieldSpec, code) } @@ -165,10 +165,10 @@ func FuzzCutBytes(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtxFuzz(ctx, t, fmt.Sprintf("cut -b %s input.txt", byteSpec), dir) + cancel() if code != 0 && code != 1 { t.Errorf("cut -b %s unexpected exit code %d", byteSpec, code) } @@ -231,11 +231,11 @@ func FuzzCutDelimiter(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel script := fmt.Sprintf("cut -d '%s' -f %s input.txt", delim, fieldSpec) _, _, code := cmdRunCtxFuzz(ctx, t, script, dir) + cancel() if code != 0 && code != 1 { t.Errorf("cut -d '%s' -f %s unexpected exit code %d", delim, fieldSpec, code) } @@ -290,10 +290,10 @@ func FuzzCutComplement(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtxFuzz(ctx, t, fmt.Sprintf("cut --complement -b %s input.txt", byteSpec), dir) + cancel() if code != 0 && code != 1 { t.Errorf("cut --complement -b %s unexpected exit code %d", byteSpec, code) } @@ -331,10 +331,10 @@ func FuzzCutStdin(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtxFuzz(ctx, t, "cut -f 1 < stdin.txt", dir) + cancel() if code != 0 && code != 1 { t.Errorf("cut stdin unexpected exit code %d", code) } diff --git a/builtins/tests/echo/echo_fuzz_test.go b/builtins/tests/echo/echo_fuzz_test.go index 7b479050..1b35fd98 100644 --- a/builtins/tests/echo/echo_fuzz_test.go +++ b/builtins/tests/echo/echo_fuzz_test.go @@ -60,10 +60,10 @@ func FuzzEcho(f *testing.F) { dir, cleanup := testutil.FuzzIterDir(t, baseDir, &counter) defer cleanup() - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := fuzzRunCtx(ctx, t, "echo '"+arg+"'", dir) + cancel() if code != 0 { t.Errorf("echo unexpected exit code %d", code) } @@ -132,10 +132,10 @@ func FuzzEchoEscapes(f *testing.F) { dir, cleanup := testutil.FuzzIterDir(t, baseDir, &counter) defer cleanup() - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := fuzzRunCtx(ctx, t, "echo -e '"+arg+"'", dir) + cancel() if code != 0 { t.Errorf("echo -e unexpected exit code %d", code) } @@ -188,10 +188,10 @@ func FuzzEchoFlagInteraction(f *testing.F) { dir, cleanup := testutil.FuzzIterDir(t, baseDir, &counter) defer cleanup() - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := fuzzRunCtx(ctx, t, "echo"+flags+" '"+arg+"'", dir) + cancel() if code != 0 { t.Errorf("echo%s unexpected exit code %d", flags, code) } diff --git a/builtins/tests/grep/grep_fuzz_test.go b/builtins/tests/grep/grep_fuzz_test.go index 1d6c475b..15d73e25 100644 --- a/builtins/tests/grep/grep_fuzz_test.go +++ b/builtins/tests/grep/grep_fuzz_test.go @@ -88,11 +88,11 @@ func FuzzGrepFileContent(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel script := "grep '" + pattern + "' input.txt" _, _, code := cmdRunCtx(ctx, t, script, dir) + cancel() if code != 0 && code != 1 && code != 2 { t.Errorf("grep unexpected exit code %d", code) } @@ -161,10 +161,10 @@ func FuzzGrepPatterns(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "grep '"+pattern+"' input.txt", dir) + cancel() if code != 0 && code != 1 && code != 2 { t.Errorf("grep pattern %q unexpected exit code %d", pattern, code) } @@ -199,10 +199,10 @@ func FuzzGrepStdin(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "grep '.' < stdin.txt", dir) + cancel() if code != 0 && code != 1 && code != 2 { t.Errorf("grep stdin unexpected exit code %d", code) } @@ -270,10 +270,10 @@ func FuzzGrepFixedStrings(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "grep -F '"+pattern+"' input.txt", dir) + cancel() if code != 0 && code != 1 && code != 2 { t.Errorf("grep -F unexpected exit code %d", code) } @@ -322,9 +322,8 @@ func FuzzGrepFlags(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel flags := "" if caseInsensitive { flags += " -i" @@ -347,6 +346,7 @@ func FuzzGrepFlags(f *testing.F) { script := "grep" + flags + " 'a' input.txt" _, _, code := cmdRunCtx(ctx, t, script, dir) + cancel() if code != 0 && code != 1 && code != 2 { t.Errorf("grep%s unexpected exit code %d", flags, code) } diff --git a/builtins/tests/head/head_differential_fuzz_test.go b/builtins/tests/head/head_differential_fuzz_test.go index 143460e8..f5cebaaf 100644 --- a/builtins/tests/head/head_differential_fuzz_test.go +++ b/builtins/tests/head/head_differential_fuzz_test.go @@ -92,13 +92,17 @@ func FuzzHeadDifferentialLines(f *testing.F) { nStr := fmt.Sprintf("%d", n) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + // Use context.Background() (not t.Context()) so the fuzz engine's + // cancellation does not kill the command mid-run; each iteration still + // enforces its own 5 s deadline. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel rshellOut, rshellErr, rshellCode := cmdRunCtx(ctx, t, fmt.Sprintf("head -n %s input.txt", nStr), dir) + cancel() - // If the fuzz engine cancelled us (fuzztime expired), bail out - // without comparing — partial output would cause false failures. + // If the fuzz engine's budget expired (t.Context(), not the per-command + // context above), bail out without comparing — partial output would cause + // false failures. if t.Context().Err() != nil { return } @@ -155,13 +159,17 @@ func FuzzHeadDifferentialBytes(f *testing.F) { nStr := fmt.Sprintf("%d", n) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + // Use context.Background() (not t.Context()) so the fuzz engine's + // cancellation does not kill the command mid-run; each iteration still + // enforces its own 5 s deadline. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel rshellOut, rshellErr, rshellCode := cmdRunCtx(ctx, t, fmt.Sprintf("head -c %s input.txt", nStr), dir) + cancel() - // If the fuzz engine cancelled us (fuzztime expired), bail out - // without comparing — partial output would cause false failures. + // If the fuzz engine's budget expired (t.Context(), not the per-command + // context above), bail out without comparing — partial output would cause + // false failures. if t.Context().Err() != nil { return } diff --git a/builtins/tests/head/head_fuzz_test.go b/builtins/tests/head/head_fuzz_test.go index 56234b9e..90e6026d 100644 --- a/builtins/tests/head/head_fuzz_test.go +++ b/builtins/tests/head/head_fuzz_test.go @@ -74,10 +74,10 @@ func FuzzHeadLines(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel stdout, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("head -n %d input.txt", n), dir) + cancel() if code != 0 && code != 1 { t.Errorf("unexpected exit code %d", code) } @@ -138,10 +138,10 @@ func FuzzHeadBytes(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel stdout, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("head -c %d input.txt", n), dir) + cancel() if code != 0 && code != 1 { t.Errorf("unexpected exit code %d", code) } @@ -189,10 +189,10 @@ func FuzzHeadStdin(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("head -n %d < stdin.txt", n), dir) + cancel() if code != 0 && code != 1 { t.Errorf("unexpected exit code %d (stdin mode)", code) } diff --git a/builtins/tests/ip/ip_fuzz_test.go b/builtins/tests/ip/ip_fuzz_test.go index f76f127d..26ebfaaf 100644 --- a/builtins/tests/ip/ip_fuzz_test.go +++ b/builtins/tests/ip/ip_fuzz_test.go @@ -145,17 +145,18 @@ func FuzzIPSubcommand(f *testing.F) { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - + defer cancel() // safety net if t.Fatal fires before explicit cancel script := "ip " + subcmd _, _, code := cmdRunCtxFuzz(ctx, t, script) + timedOut := ctx.Err() == context.DeadlineExceeded // capture before cancel() + cancel() if code == -1 { return // shell/parse error before the builtin ran — not our bug } if code != 0 && code != 1 { t.Errorf("ip %q: unexpected exit code %d", subcmd, code) } - if ctx.Err() != nil { + if timedOut { t.Errorf("ip %q: timed out (possible hang)", subcmd) } }) @@ -216,20 +217,21 @@ func FuzzIPFlags(f *testing.F) { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - + defer cancel() // safety net if t.Fatal fires before explicit cancel script := "ip " + flags if subcmd != "" { script += " " + subcmd } _, _, code := cmdRunCtxFuzz(ctx, t, script) + timedOut := ctx.Err() == context.DeadlineExceeded // capture before cancel() + cancel() if code == -1 { return // shell/parse error before the builtin ran — not our bug } if code != 0 && code != 1 { t.Errorf("ip %q %q: unexpected exit code %d", flags, subcmd, code) } - if ctx.Err() != nil { + if timedOut { t.Errorf("ip %q %q: timed out", flags, subcmd) } }) diff --git a/builtins/tests/ls/ls_fuzz_test.go b/builtins/tests/ls/ls_fuzz_test.go index 25d855a3..52c43732 100644 --- a/builtins/tests/ls/ls_fuzz_test.go +++ b/builtins/tests/ls/ls_fuzz_test.go @@ -94,10 +94,10 @@ func FuzzLsFlags(f *testing.F) { flags += " -F" } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "ls"+flags, dir) + cancel() if code != 0 && code != 1 { t.Errorf("ls%s unexpected exit code %d", flags, code) } @@ -142,10 +142,10 @@ func FuzzLsRecursive(f *testing.F) { current = subdir } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "ls -R", dir) + cancel() if code != 0 && code != 1 { t.Errorf("ls -R unexpected exit code %d", code) } @@ -201,10 +201,10 @@ func FuzzLsHumanReadable(f *testing.F) { } fh.Close() - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "ls -lh testfile.bin", dir) + cancel() if code != 0 && code != 1 { t.Errorf("ls -lh unexpected exit code %d", code) } @@ -259,10 +259,10 @@ func FuzzLsMultipleFiles(f *testing.F) { flags += " -S" } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "ls"+flags, dir) + cancel() if code != 0 && code != 1 { t.Errorf("ls%s unexpected exit code %d", flags, code) } diff --git a/builtins/tests/ss/ss_fuzz_test.go b/builtins/tests/ss/ss_fuzz_test.go index 8d133428..7090b157 100644 --- a/builtins/tests/ss/ss_fuzz_test.go +++ b/builtins/tests/ss/ss_fuzz_test.go @@ -139,9 +139,9 @@ func FuzzSSFlags(f *testing.F) { t.Logf("script: %s", script) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtxFuzzSS(ctx, t, script) + cancel() if code != 0 && code != 1 { t.Errorf("unexpected exit code %d for flags %q", code, flags) } diff --git a/builtins/tests/strings_cmd/strings_fuzz_test.go b/builtins/tests/strings_cmd/strings_fuzz_test.go index 3e93219a..9f484544 100644 --- a/builtins/tests/strings_cmd/strings_fuzz_test.go +++ b/builtins/tests/strings_cmd/strings_fuzz_test.go @@ -92,10 +92,10 @@ func FuzzStrings(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "strings input.bin", dir) + cancel() if code != 0 && code != 1 { t.Errorf("strings unexpected exit code %d", code) } @@ -144,10 +144,10 @@ func FuzzStringsMinLen(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("strings -n %d input.bin", minLen), dir) + cancel() if code != 0 && code != 1 { t.Errorf("strings -n %d unexpected exit code %d", minLen, code) } @@ -194,10 +194,10 @@ func FuzzStringsRadix(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("strings -t %s input.bin", radix), dir) + cancel() if code != 0 && code != 1 { t.Errorf("strings -t %s unexpected exit code %d", radix, code) } @@ -232,10 +232,10 @@ func FuzzStringsStdin(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "strings < stdin.bin", dir) + cancel() if code != 0 && code != 1 { t.Errorf("strings stdin unexpected exit code %d", code) } diff --git a/builtins/tests/tail/tail_differential_fuzz_test.go b/builtins/tests/tail/tail_differential_fuzz_test.go index 3d6dd2f9..09943915 100644 --- a/builtins/tests/tail/tail_differential_fuzz_test.go +++ b/builtins/tests/tail/tail_differential_fuzz_test.go @@ -86,9 +86,9 @@ func FuzzTailDifferential(f *testing.F) { nStr := fmt.Sprintf("%d", n) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - + defer cancel() // safety net if t.Fatal fires before explicit cancel rshellOut, rshellErr, rshellCode := cmdRunCtx(ctx, t, fmt.Sprintf("tail -n %s input.txt", nStr), dir) + cancel() if isSandboxError(rshellErr) { t.Skip("skipping: sandbox restriction") diff --git a/builtins/tests/tail/tail_fuzz_test.go b/builtins/tests/tail/tail_fuzz_test.go index 579c5366..266a7f04 100644 --- a/builtins/tests/tail/tail_fuzz_test.go +++ b/builtins/tests/tail/tail_fuzz_test.go @@ -67,9 +67,9 @@ func FuzzTailLines(f *testing.F) { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - + defer cancel() // safety net if t.Fatal fires before explicit cancel stdout, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("tail -n %d input.txt", n), dir) + cancel() if code != 0 && code != 1 { t.Errorf("tail -n %d unexpected exit code %d", n, code) } @@ -124,9 +124,9 @@ func FuzzTailBytes(f *testing.F) { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - + defer cancel() // safety net if t.Fatal fires before explicit cancel stdout, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("tail -c %d input.txt", n), dir) + cancel() if code != 0 && code != 1 { t.Errorf("tail -c %d unexpected exit code %d", n, code) } @@ -171,9 +171,9 @@ func FuzzTailStdin(f *testing.F) { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("tail -n %d < stdin.txt", n), dir) + cancel() if code != 0 && code != 1 { t.Errorf("tail stdin unexpected exit code %d", code) } @@ -218,9 +218,9 @@ func FuzzTailLinesOffset(f *testing.F) { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("tail -n +%d input.txt", n), dir) + cancel() if code != 0 && code != 1 { t.Errorf("tail -n +%d unexpected exit code %d", n, code) } @@ -262,9 +262,9 @@ func FuzzTailBytesOffset(f *testing.F) { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, fmt.Sprintf("tail -c +%d input.txt", n), dir) + cancel() if code != 0 && code != 1 { t.Errorf("tail -c +%d unexpected exit code %d", n, code) } diff --git a/builtins/tests/testcmd/testcmd_fuzz_test.go b/builtins/tests/testcmd/testcmd_fuzz_test.go index acb467df..a051108c 100644 --- a/builtins/tests/testcmd/testcmd_fuzz_test.go +++ b/builtins/tests/testcmd/testcmd_fuzz_test.go @@ -84,11 +84,11 @@ func FuzzTestStringOps(f *testing.F) { return } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel script := fmt.Sprintf("test '%s' %s '%s'", left, op, right) _, _, code := cmdRunCtx(ctx, t, script, baseDir) + cancel() if code != 0 && code != 1 && code != 2 { t.Errorf("test string op unexpected exit code %d", code) } @@ -131,11 +131,11 @@ func FuzzTestIntegerOps(f *testing.F) { return } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel script := fmt.Sprintf("test %d %s %d", left, op, right) _, _, code := cmdRunCtx(ctx, t, script, baseDir) + cancel() if code != 0 && code != 1 && code != 2 { t.Errorf("test %d %s %d unexpected exit code %d", left, op, right, code) } @@ -182,11 +182,11 @@ func FuzzTestFileOps(f *testing.F) { } } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel script := fmt.Sprintf("test %s %s", op, target) _, _, code := cmdRunCtx(ctx, t, script, dir) + cancel() if code != 0 && code != 1 && code != 2 { t.Errorf("test %s unexpected exit code %d", op, code) } @@ -235,11 +235,11 @@ func FuzzTestStringUnary(f *testing.F) { } } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel script := fmt.Sprintf("test %s '%s'", op, arg) _, _, code := cmdRunCtx(ctx, t, script, baseDir) + cancel() if code != 0 && code != 1 && code != 2 { t.Errorf("test %s unexpected exit code %d", op, code) } @@ -321,11 +321,11 @@ func FuzzTestNesting(f *testing.F) { } } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel script := fmt.Sprintf("test %s", expr) _, _, code := cmdRunCtx(ctx, t, script, baseDir) + cancel() if code != 0 && code != 1 && code != 2 { t.Errorf("test %q unexpected exit code %d", expr, code) } diff --git a/builtins/tests/uniq/uniq_fuzz_test.go b/builtins/tests/uniq/uniq_fuzz_test.go index 82228314..c2568ad5 100644 --- a/builtins/tests/uniq/uniq_fuzz_test.go +++ b/builtins/tests/uniq/uniq_fuzz_test.go @@ -75,10 +75,10 @@ func FuzzUniq(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := fuzzRunCtx(ctx, t, "uniq input.txt", dir) + cancel() if code != 0 && code != 1 { t.Errorf("uniq unexpected exit code %d", code) } @@ -114,10 +114,10 @@ func FuzzUniqCount(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := fuzzRunCtx(ctx, t, "uniq -c input.txt", dir) + cancel() if code != 0 && code != 1 { t.Errorf("uniq -c unexpected exit code %d", code) } @@ -194,10 +194,10 @@ func FuzzUniqFlags(f *testing.F) { flags += fmt.Sprintf(" -w %d", checkChars) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := fuzzRunCtx(ctx, t, "uniq"+flags+" input.txt", dir) + cancel() if code != 0 && code != 1 { t.Errorf("uniq%s unexpected exit code %d", flags, code) } @@ -227,10 +227,10 @@ func FuzzUniqStdin(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := fuzzRunCtx(ctx, t, "uniq < stdin.txt", dir) + cancel() if code != 0 && code != 1 { t.Errorf("uniq stdin unexpected exit code %d", code) } diff --git a/builtins/tests/wc/wc_differential_fuzz_test.go b/builtins/tests/wc/wc_differential_fuzz_test.go index 028b1c38..8dd3a6d0 100644 --- a/builtins/tests/wc/wc_differential_fuzz_test.go +++ b/builtins/tests/wc/wc_differential_fuzz_test.go @@ -86,13 +86,17 @@ func FuzzWcDifferentialLines(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + // Use context.Background() (not t.Context()) so the fuzz engine's + // cancellation does not kill the command mid-run; each iteration still + // enforces its own 5 s deadline. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel rshellOut, rshellErr, rshellCode := cmdRunCtx(ctx, t, "wc -l input.txt", dir) + cancel() - // If the fuzz engine cancelled us (fuzztime expired), bail out - // without comparing — partial output would cause false failures. + // If the fuzz engine's budget expired (t.Context(), not the per-command + // context above), bail out without comparing — partial output would cause + // false failures. if t.Context().Err() != nil { return } @@ -145,10 +149,13 @@ func FuzzWcDifferentialWords(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + // Use context.Background() (not t.Context()) so the fuzz engine's + // cancellation does not kill the command mid-run; each iteration still + // enforces its own 5 s deadline. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel rshellOut, rshellErr, rshellCode := cmdRunCtx(ctx, t, "wc -w input.txt", dir) + cancel() if t.Context().Err() != nil { return @@ -201,10 +208,13 @@ func FuzzWcDifferentialBytes(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + // Use context.Background() (not t.Context()) so the fuzz engine's + // cancellation does not kill the command mid-run; each iteration still + // enforces its own 5 s deadline. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel rshellOut, rshellErr, rshellCode := cmdRunCtx(ctx, t, "wc -c input.txt", dir) + cancel() if t.Context().Err() != nil { return diff --git a/builtins/tests/wc/wc_fuzz_test.go b/builtins/tests/wc/wc_fuzz_test.go index dc0a38e3..70ea6039 100644 --- a/builtins/tests/wc/wc_fuzz_test.go +++ b/builtins/tests/wc/wc_fuzz_test.go @@ -70,10 +70,10 @@ func FuzzWc(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "wc input.txt", dir) + cancel() if code != 0 && code != 1 { t.Errorf("wc unexpected exit code %d", code) } @@ -108,10 +108,10 @@ func FuzzWcLines(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "wc -l input.txt", dir) + cancel() if code != 0 && code != 1 { t.Errorf("wc -l unexpected exit code %d", code) } @@ -144,10 +144,10 @@ func FuzzWcBytes(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "wc -c input.txt", dir) + cancel() if code != 0 && code != 1 { t.Errorf("wc -c unexpected exit code %d", code) } @@ -187,10 +187,10 @@ func FuzzWcChars(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "wc -m input.txt", dir) + cancel() if code != 0 && code != 1 { t.Errorf("wc -m unexpected exit code %d", code) } @@ -228,10 +228,10 @@ func FuzzWcStdin(f *testing.F) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() // safety net if t.Fatal fires before explicit cancel _, _, code := cmdRunCtx(ctx, t, "wc < stdin.txt", dir) + cancel() if code != 0 && code != 1 { t.Errorf("wc stdin unexpected exit code %d", code) } diff --git a/interp/api.go b/interp/api.go index af1450b1..2fd490fe 100644 --- a/interp/api.go +++ b/interp/api.go @@ -19,6 +19,7 @@ import ( "io" "os" "strings" + "time" "mvdan.cc/sh/v3/expand" "mvdan.cc/sh/v3/syntax" @@ -113,6 +114,10 @@ type runnerState struct { lastExit exitStatus lastExpandExit exitStatus // used to surface exit statuses while expanding fields + + // startTime is captured once at the beginning of Run() and passed to + // all builtin invocations so they share a consistent time reference. + startTime time.Time } // A Runner interprets shell programs. It can be reused, but it is not safe for @@ -313,6 +318,8 @@ func (r *Runner) Reset() { } } // Reset only the mutable state; config is preserved. + // startTime is intentionally zeroed here by the struct literal; it will + // be set again by Run() before any builtin is invoked. r.runnerState = runnerState{ Dir: r.origDir, Params: r.origParams, @@ -366,6 +373,7 @@ func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { return r.exit.err } } + r.startTime = time.Now() r.fillExpandConfig(ctx) if err := validateNode(node); err != nil { fmt.Fprintln(r.stderr, err) @@ -481,14 +489,15 @@ func (r *Runner) subshell(background bool) *Runner { r2 := &Runner{ runnerConfig: r.runnerConfig, runnerState: runnerState{ - Dir: r.Dir, - Params: r.Params, - stdin: r.stdin, - stdout: r.stdout, - stderr: r.stderr, - filename: r.filename, - exit: r.exit, - lastExit: r.lastExit, + Dir: r.Dir, + Params: r.Params, + stdin: r.stdin, + stdout: r.stdout, + stderr: r.stderr, + filename: r.filename, + exit: r.exit, + lastExit: r.lastExit, + startTime: r.startTime, }, } r2.writeEnv = newOverlayEnviron(r.writeEnv, background) diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 7f220cee..7ccdb0fb 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -13,7 +13,6 @@ import ( "os" "path/filepath" "sync" - "time" "mvdan.cc/sh/v3/expand" "mvdan.cc/sh/v3/syntax" @@ -296,7 +295,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { return r.sandbox.Access(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir, mode) }, PortableErr: allowedpaths.PortableErrMsg, - Now: time.Now, + Now: r.startTime, FileIdentity: func(path string, info fs.FileInfo) (builtins.FileID, bool) { absPath := path if !filepath.IsAbs(absPath) { diff --git a/interp/start_time_test.go b/interp/start_time_test.go new file mode 100644 index 00000000..c878796a --- /dev/null +++ b/interp/start_time_test.go @@ -0,0 +1,109 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package interp + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" +) + +func parseScript(t *testing.T, src string) *syntax.File { + t.Helper() + prog, err := syntax.NewParser().Parse(strings.NewReader(src), "") + require.NoError(t, err) + return prog +} + +// TestStartTimeZeroBeforeRun verifies that startTime is not set until Run is +// called, so callers cannot accidentally observe a stale time from a previous +// run. Uses New() directly (not newResetRunner) to check the initial zero-value +// state before any Run or Reset call. +func TestStartTimeZeroBeforeRun(t *testing.T) { + r, err := New(AllowAllCommands()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + assert.True(t, r.startTime.IsZero(), "startTime should be zero before Run") +} + +// TestStartTimeSetByRun verifies that Run captures the current time into +// startTime before executing any builtins. +func TestStartTimeSetByRun(t *testing.T) { + r, err := New(AllowAllCommands()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + before := time.Now() + err = r.Run(context.Background(), parseScript(t, "true")) + after := time.Now() + require.NoError(t, err) + + assert.False(t, r.startTime.IsZero(), "startTime should be set after Run") + assert.True(t, !r.startTime.Before(before), "startTime should be >= time before Run") + assert.True(t, !r.startTime.After(after), "startTime should be <= time after Run") +} + +// TestStartTimeUpdatesOnSubsequentRun verifies that each Run call captures a +// fresh timestamp, so commands in different runs do not share a stale time. +func TestStartTimeUpdatesOnSubsequentRun(t *testing.T) { + r, err := New(AllowAllCommands()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + prog := parseScript(t, "true") + + err = r.Run(context.Background(), prog) + require.NoError(t, err) + first := r.startTime + + // Sleep to ensure the wall clock advances between runs. Windows default + // timer resolution is ~15 ms, so 50 ms provides a safe margin. + time.Sleep(50 * time.Millisecond) + + err = r.Run(context.Background(), prog) + require.NoError(t, err) + second := r.startTime + + assert.True(t, second.After(first), "startTime should advance between Run calls") +} + +// TestStartTimePropagatedToSubshell verifies that a child runner created by +// subshell() inherits the parent's startTime so builtins in subshells and +// pipelines use the correct time reference. +func TestStartTimePropagatedToSubshell(t *testing.T) { + r, err := New(AllowAllCommands()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + err = r.Run(context.Background(), parseScript(t, "true")) + require.NoError(t, err) + require.False(t, r.startTime.IsZero()) + + sub := r.subshell(false) + assert.Equal(t, r.startTime, sub.startTime, + "subshell must inherit parent startTime") +} + +// TestStartTimeResetToZeroByReset verifies that Reset clears startTime so that +// a runner that has been reset but not yet re-run does not expose the previous +// run's timestamp. +func TestStartTimeResetToZeroByReset(t *testing.T) { + r, err := New(AllowAllCommands()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + err = r.Run(context.Background(), parseScript(t, "true")) + require.NoError(t, err) + require.False(t, r.startTime.IsZero(), "startTime should be set after Run") + + r.Reset() + assert.True(t, r.startTime.IsZero(), "startTime should be cleared by Reset") +}