Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions allowedsymbols/symbols_builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,6 @@ var builtinPerCommandSymbols = map[string][]string{
"io/fs.ModeSocket", // file mode bit constant for sockets; pure constant.
"io/fs.ModeSymlink", // file mode bit constant for symlinks; pure constant.
"io/fs.ReadDirFile", // read-only directory handle interface; no write capability.
"math.Ceil", // pure arithmetic; no side effects.
"math.Floor", // pure arithmetic; no side effects.
"math.MaxInt64", // integer constant; no side effects.
"os.IsNotExist", // checks if error is "not exist"; pure function, no I/O.
"os.PathError", // error type for path operations; pure type.
Expand All @@ -89,10 +87,6 @@ var builtinPerCommandSymbols = map[string][]string{
"strconv.ParseInt", // string-to-int conversion; pure function, no I/O.
"strings.HasPrefix", // pure function for prefix matching; no I/O.
"strings.ToLower", // converts string to lowercase; pure function, no I/O.
"time.Duration", // duration type; pure integer alias, no I/O.
"time.Hour", // constant representing one hour; no side effects.
"time.Minute", // constant representing one minute; no side effects.
"time.Second", // constant representing one second; no side effects.
"time.Time", // time value type; pure data, no side effects.
"unicode/utf8.DecodeRuneInString", // decodes first UTF-8 rune from a string; pure function, no I/O.
},
Expand Down Expand Up @@ -328,8 +322,6 @@ var builtinAllowedSymbols = []string{
"io/fs.ModeSticky", // file mode bit constant for sticky bit; pure constant.
"io/fs.ModeSymlink", // file mode bit constant for symlinks; pure constant.
"io/fs.ReadDirFile", // read-only directory handle interface; no write capability.
"math.Ceil", // pure arithmetic; no side effects.
"math.Floor", // pure arithmetic; no side effects.
"math.Inf", // returns positive or negative infinity; pure function, no I/O.
"math.MaxInt32", // integer constant; no side effects.
"math.MaxInt64", // integer constant; no side effects.
Expand Down Expand Up @@ -368,10 +360,6 @@ var builtinAllowedSymbols = []string{
"strings.TrimSpace", // removes leading/trailing whitespace; pure function.
"syscall.EISDIR", // error number constant for "is a directory"; pure constant, no I/O.
"syscall.Errno", // error type for system call error numbers; pure type, no I/O.
"time.Duration", // duration type; pure integer alias, no I/O.
"time.Hour", // constant representing one hour; no side effects.
"time.Minute", // constant representing one minute; no side effects.
"time.Second", // constant representing one second; no side effects.
"time.Time", // time value type; pure data, no side effects.
"unicode.Cc", // control character category range table; pure data, no I/O.
"unicode.Cf", // format character category range table; pure data, no I/O.
Expand Down
1 change: 0 additions & 1 deletion allowedsymbols/symbols_interp.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ var interpAllowedSymbols = []string{
"sync.Mutex", // mutual exclusion lock; concurrency primitive, no I/O.
"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.

// --- mvdan.cc/sh/v3/expand --- (shell word expansion library)

Expand Down
29 changes: 29 additions & 0 deletions allowedsymbols/symbols_restrictedtime.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// 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 allowedsymbols

// restrictedtimeAllowedSymbols lists every "importpath.Symbol" that may be used by
// non-test Go files in restrictedtime/. Each entry must be in "importpath.Symbol"
// form, where importpath is the full Go import path.
//
// Each symbol must have a comment explaining what it does and why it is safe
// to use inside the time-comparison package.
//
// Internal module imports (github.com/DataDog/rshell/*) are auto-allowed
// and do not appear here.
//
// The permanently banned packages (reflect, unsafe) apply here too.
var restrictedtimeAllowedSymbols = []string{
"math.Ceil", // pure arithmetic; rounds up fractional minutes for ceiling bucketing.
"math.Floor", // pure arithmetic; rounds down fractional days for floor bucketing.
"math.MaxInt64", // integer constant; used for overflow guards.
"time.Duration", // duration type; pure integer alias, no I/O.
"time.Hour", // constant representing one hour; no side effects.
"time.Minute", // constant representing one minute; no side effects.
"time.Now", // captures invocation timestamp once; read-only, no mutation.
"time.Second", // constant representing one second; no side effects.
"time.Time", // time value type; pure data, no side effects.
}
37 changes: 37 additions & 0 deletions allowedsymbols/symbols_restrictedtime_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// 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 allowedsymbols

import (
"strings"
"testing"
)

// restrictedtimeCheckConfig returns the allowedSymbolsConfig used to enforce
// symbol-level import restrictions on restrictedtime/. Verification tests reuse
// this function to ensure they test the exact same configuration.
func restrictedtimeCheckConfig() allowedSymbolsConfig {
return allowedSymbolsConfig{
Symbols: restrictedtimeAllowedSymbols,
TargetDir: "restrictedtime",
CollectFiles: func(dir string) ([]string, error) {
return collectFlatGoFiles(dir)
},
ExemptImport: func(importPath string) bool {
return strings.HasPrefix(importPath, "github.com/DataDog/rshell/")
},
ListName: "restrictedtimeAllowedSymbols",
MinFiles: 1,
}
}

// TestTimecompAllowedSymbols enforces symbol-level import restrictions on
// non-test Go files in restrictedtime/. Every imported symbol must be explicitly
// listed in restrictedtimeAllowedSymbols. Internal module imports
// (github.com/DataDog/rshell/*) are auto-allowed.
func TestTimecompAllowedSymbols(t *testing.T) {
checkAllowedSymbols(t, restrictedtimeCheckConfig())
}
67 changes: 67 additions & 0 deletions allowedsymbols/symbols_restrictedtime_verification_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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 allowedsymbols

import (
"path/filepath"
"strings"
"testing"
)

// restrictedtimeVerifyCfg returns a restrictedtimeCheckConfig with RepoRootOverride and
// Errors set for verification testing.
func restrictedtimeVerifyCfg(tempRoot string, errs *[]string) allowedSymbolsConfig {
cfg := restrictedtimeCheckConfig()
cfg.RepoRootOverride = tempRoot
cfg.Errors = errs
return cfg
}

func TestVerificationTimecompCleanPass(t *testing.T) {
root := repoRoot(t)
tmp := t.TempDir()
copyDir(t, filepath.Join(root, "restrictedtime"), filepath.Join(tmp, "restrictedtime"))

var errs []string
checkAllowedSymbols(t, restrictedtimeVerifyCfg(tmp, &errs))

if len(errs) > 0 {
t.Errorf("expected no errors on clean copy, got:\n%s", strings.Join(errs, "\n"))
}
}

func TestVerificationTimecompUnlistedSymbol(t *testing.T) {
root := repoRoot(t)
tmp := t.TempDir()
copyDir(t, filepath.Join(root, "restrictedtime"), filepath.Join(tmp, "restrictedtime"))

target := findFirstFlatGoFile(t, filepath.Join(tmp, "restrictedtime"))
injectUnlistedSymbol(t, target)

var errs []string
checkAllowedSymbols(t, restrictedtimeVerifyCfg(tmp, &errs))

if !errContains(errs, "os") || !errContains(errs, "not in the allowlist") {
t.Errorf("expected 'not in the allowlist' error for os import, got: %v", errs)
}
}

func TestVerificationTimecompExemptImport(t *testing.T) {
root := repoRoot(t)
tmp := t.TempDir()
copyDir(t, filepath.Join(root, "restrictedtime"), filepath.Join(tmp, "restrictedtime"))

target := findFirstFlatGoFile(t, filepath.Join(tmp, "restrictedtime"))
// Internal module imports (github.com/DataDog/rshell/*) are exempt.
injectImport(t, target, `fakepkg "github.com/DataDog/rshell/fakepkg"`, "var _ = fakepkg.Foo")

var errs []string
checkAllowedSymbols(t, restrictedtimeVerifyCfg(tmp, &errs))

if errContains(errs, "github.com/DataDog/rshell/fakepkg") {
t.Errorf("exempt import should not be flagged, got: %v", errs)
}
}
15 changes: 11 additions & 4 deletions builtins/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,17 @@ 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
// MatchMtime checks whether a file's modification time satisfies a
// -mtime predicate (day-granularity). cmp: -1 = less, 0 = exact, 1 = more.
MatchMtime func(modTime time.Time, n int64, cmp int) bool

// MatchMmin checks whether a file's modification time satisfies a
// -mmin predicate (minute-granularity). cmp: -1 = less, 0 = exact, 1 = more.
MatchMmin func(modTime time.Time, n int64, cmp int) bool

// IsRecentEnough reports whether modTime is within the given number of
// months before the invocation time and not in the future.
IsRecentEnough func(modTime time.Time, months int) bool

// FileIdentity extracts canonical file identity from FileInfo.
// On Unix: dev+inode from Stat_t. On Windows: volume serial + file index
Expand Down
76 changes: 6 additions & 70 deletions builtins/find/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ package find
import (
"context"
iofs "io/fs"
"math"
"time"

"github.com/DataDog/rshell/builtins"
Expand All @@ -24,7 +23,6 @@ type evalResult struct {
type evalContext struct {
callCtx *builtins.CallContext
ctx context.Context
now time.Time
relPath string // path relative to starting point
info iofs.FileInfo // file info (lstat or stat depending on -L)
depth int // current depth
Expand Down Expand Up @@ -175,76 +173,14 @@ func evalNewer(ec *evalContext, refPath string) bool {
return ec.info.ModTime().After(refTime)
}

// evalMtime checks modification time in days.
// GNU find uses different comparison strategies for -mtime:
// - Exact (N): day-bucketed comparison — N*86400 <= delta < (N+1)*86400.
// - +N: raw second comparison — delta > (N+1)*86400.
// - -N: raw second comparison — delta < N*86400.
//
// GNU find captures 'now' via time() (second precision) but gets file mtime
// from stat() (nanosecond precision). This means for very fresh files,
// delta can be slightly negative, causing -mtime -0 to match files created
// within the same second. We replicate this by truncating now to seconds
// for +N/-N comparisons.
//
// maxMtimeN is the largest N for which (N+1)*24*time.Hour does not overflow.
const maxMtimeN = int64(math.MaxInt64/(int64(24*time.Hour))) - 1

// evalMtime delegates to CallContext.MatchMtime which encapsulates
// the GNU find day-bucketing logic without exposing wall-clock time.
func evalMtime(ec *evalContext, n int64, cmp cmpOp) bool {
modTime := ec.info.ModTime()
switch cmp {
case cmpMore: // +N: strictly older than (N+1) days
if n > maxMtimeN {
return false // threshold beyond representable duration
}
// Truncate now to second precision to match GNU find's time().
diff := ec.now.Truncate(time.Second).Sub(modTime)
return diff >= time.Duration(n+1)*24*time.Hour
case cmpLess: // -N: strictly newer than N days
if n > maxMtimeN {
return true // threshold beyond representable duration
}
// Truncate now to second precision to match GNU find's time().
diff := ec.now.Truncate(time.Second).Sub(modTime)
return diff < time.Duration(n)*24*time.Hour
default: // N: day-bucketed exact match
// Do not clamp negative diff — future-dated files must produce
// negative day buckets so they never match non-negative N,
// matching GNU find behavior.
diff := ec.now.Sub(modTime)
days := int64(math.Floor(diff.Hours() / 24))
return days == n
}
return ec.callCtx.MatchMtime(ec.info.ModTime(), n, int(cmp))
}

// evalMmin checks modification time in minutes.
// GNU find uses different comparison strategies:
// - Exact (N): ceiling-bucketed comparison — a 5s-old file is in bucket 1.
// - +N: raw second comparison — delta_seconds > N*60.
// - -N: raw second comparison — delta_seconds < N*60.
//
// This matches GNU findutils behavior where +N/-N compare against raw
// seconds while exact N uses a window check.
// maxMminN is the largest N for which time.Duration(N)*time.Minute
// does not overflow int64 nanoseconds.
const maxMminN = int64(math.MaxInt64 / int64(time.Minute))

// evalMmin delegates to CallContext.MatchMmin which encapsulates
// the GNU find minute-bucketing logic without exposing wall-clock time.
func evalMmin(ec *evalContext, n int64, cmp cmpOp) bool {
modTime := ec.info.ModTime()
diff := ec.now.Sub(modTime)
switch cmp {
case cmpMore: // +N: strictly older than N minutes
if n > maxMminN {
return false // threshold is beyond representable duration; nothing qualifies
}
return diff > time.Duration(n)*time.Minute
case cmpLess: // -N: strictly newer than N minutes
if n > maxMminN {
return true // threshold is beyond representable duration; everything qualifies
}
return diff < time.Duration(n)*time.Minute
default: // N: ceiling-bucketed exact match
mins := int64(math.Ceil(diff.Minutes()))
return mins == n
}
return ec.callCtx.MatchMmin(ec.info.ModTime(), n, int(cmp))
}
Loading
Loading