Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ jobs:
- pkg: ./builtins/tests/xargs/
name: xargs
corpus_path: builtins/tests/xargs
- pkg: ./builtins/tests/truncate/
name: truncate
corpus_path: builtins/tests/truncate
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
Expand Down
1 change: 1 addition & 0 deletions SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c
- ✅ `test EXPRESSION` / `[ EXPRESSION ]` — evaluate conditional expression (file tests, string/integer comparison, logical operators)
- ✅ `tr [-cdsCt] SET1 [SET2]` — translate, squeeze, and/or delete characters from stdin
- ✅ `true` — return exit code 0
- ✅ `truncate [-c] -s SIZE FILE...` — shrink or extend each FILE to SIZE bytes inside `AllowedPaths` (creates files unless `-c`); `-s` accepts the GNU suffix grammar — for K/M/G/T the leading letter is case-insensitive and the trailing `B`/`iB` is case-sensitive (e.g. `1k`/`1K`/`1kiB`/`1KiB` = 1024, `1kB`/`1KB` = 1000), for P/E the leading letter is uppercase-only (matching GNU); Z/Y/R/Q are rejected because their multipliers exceed int64 (GNU rejects these too on 64-bit-uintmax_t systems); `-r`/`--reference`, `-o`/`--io-blocks`, and the GNU relative-size modifiers (`+N`/`-N`/`<N`/`>N`/`/N`/`%N`) are rejected
- ✅ `uname [-asnrvm]` — print system information (Linux only; reads from `/proc/sys/kernel/`, respects `--proc-path`)
- ✅ `uniq [OPTION]... [INPUT]` — report or omit repeated lines
- ✅ `wc [-l] [-w] [-c] [-m] [-L] [FILE]...` — count lines, words, bytes, characters, or max line length
Expand Down
87 changes: 87 additions & 0 deletions allowedpaths/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"path/filepath"
"slices"
"strings"
"syscall"
)

// Access mode bits for permission checks.
Expand Down Expand Up @@ -367,6 +368,92 @@ func (s *Sandbox) Open(path string, cwd string, flag int, perm os.FileMode) (io.
return f, nil
}

// Truncate sets the size of the file at path to size bytes. When create is
// true, a missing file is created with the open(2) permissive default
// (0666 & ~umask), matching GNU truncate and bash redirect semantics; the
// process umask is what actually decides the mode. When create is false,
// a missing file returns os.ErrNotExist (the caller, e.g. truncate -c,
// decides whether to treat that as an error or a silent skip).
//
// Like Open, the operation goes through os.Root for atomic openat-based path
// validation. The cross-root symlink fallback is intentionally NOT used:
// resolving a symlink that escapes one root and then writing through the
// resolved path is the classic TOCTOU footgun. Writes must stay within a
// single allowed root.
//
// Non-regular targets (FIFO, socket, char/block device) are rejected by an
// atomic open-and-fstat sequence:
//
// 1. The open includes O_NONBLOCK so that an O_WRONLY open of a FIFO with
// no reader returns ENXIO immediately instead of blocking the shell
// waiting for a connection. (O_NONBLOCK is benign on regular files —
// it sets the fd's status flag but does not change open semantics —
// and is a no-op on platforms where the constant is zero, e.g. Windows.)
// 2. After a successful open, fstat on the returned fd verifies the file
// is regular before any ftruncate runs. This closes the TOCTOU window
// that a pre-open Stat would have left open: even if a regular file
// is swapped for a FIFO between path resolution and the open syscall,
// the resulting fd is rejected before the size change reaches the
// kernel.
//
// Negative sizes are rejected with EINVAL. Sizes within int64 range are
// passed through to the kernel; the kernel/filesystem rejects values it
// cannot represent (e.g. exceeding the filesystem's maximum file size).
//
// The flag passed to OpenFile is constructed locally from a fixed set of
// constants, so the open-flag allowlist enforced in Open is not relevant
// here — there is no caller-controlled flag bit that could leak through.
func (s *Sandbox) Truncate(path string, cwd string, size int64, create bool) error {
if size < 0 {
return &os.PathError{Op: "truncate", Path: path, Err: syscall.EINVAL}
}

absPath := toAbs(path, cwd)

ar, relPath, ok := s.resolve(absPath)
if !ok {
return &os.PathError{Op: "truncate", Path: path, Err: os.ErrPermission}
}

flag := os.O_WRONLY | syscall.O_NONBLOCK
if create {
flag |= os.O_CREATE
}
// 0666 lets the process umask determine the final mode (open(2) applies
// mode & ~umask). This matches GNU truncate and bash >FILE behaviour:
// `umask 000; truncate -s 0 f` produces 0666; `umask 022` yields 0644.
f, err := ar.root.OpenFile(relPath, flag, 0666)
if err != nil {
// Return the raw error so callers can use errors.Is against
// fs.ErrNotExist / fs.ErrPermission. The handler renders user-
// facing messages via PortableErrMsg, so the wrapping that
// PortablePathError performs is not needed here. Wrapping would
// hide os.ErrNotExist behind a fresh errors.New value, which
// would silently break the truncate -c silent-skip path.
return err
}
// fstat the fd we actually opened (not the path) so a swap between
// path resolution and open is caught before ftruncate runs.
info, err := f.Stat()
if err != nil {
f.Close()
return err
}
if !info.Mode().IsRegular() {
f.Close()
return &os.PathError{Op: "truncate", Path: path, Err: errors.New("not a regular file")}
}
truncErr := f.Truncate(size)
// Surface a deferred Close error only when Truncate itself succeeded;
// a failed Close after a successful ftruncate is the only case where a
// Close error reflects user-visible data loss (flush failure on write-back).
closeErr := f.Close()
if truncErr != nil {
return truncErr
}
return closeErr
}

// ReadDir implements the restricted directory-read policy.
func (s *Sandbox) ReadDir(path string, cwd string) ([]fs.DirEntry, error) {
return s.readDirN(path, cwd, -1)
Expand Down
131 changes: 131 additions & 0 deletions allowedpaths/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,137 @@ func TestSandboxWriteRejectsUnknownFlag(t *testing.T) {
assert.ErrorIs(t, err, os.ErrPermission)
}

// TestSandboxTruncateMethodShrink covers the happy path of the new
// Sandbox.Truncate API: an existing file in an allowed root is shrunk to
// the requested size, leaving the leading bytes intact.
func TestSandboxTruncateMethodShrink(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "log.txt")
require.NoError(t, os.WriteFile(path, []byte("0123456789"), 0644))

sb, _, err := New([]string{dir})
require.NoError(t, err)
defer sb.Close()

require.NoError(t, sb.Truncate("log.txt", dir, 4, false))

got, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, "0123", string(got))
}

// TestSandboxTruncateMethodExtend covers the case where SIZE is larger
// than the current file: the file is zero-extended.
func TestSandboxTruncateMethodExtend(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "log.txt")
require.NoError(t, os.WriteFile(path, []byte("abc"), 0644))

sb, _, err := New([]string{dir})
require.NoError(t, err)
defer sb.Close()

require.NoError(t, sb.Truncate("log.txt", dir, 1024, false))

info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, int64(1024), info.Size())
}

// TestSandboxTruncateMethodCreates covers the create-by-default behaviour
// used when truncate is invoked without -c.
func TestSandboxTruncateMethodCreates(t *testing.T) {
dir := t.TempDir()

sb, _, err := New([]string{dir})
require.NoError(t, err)
defer sb.Close()

require.NoError(t, sb.Truncate("fresh.txt", dir, 100, true))

info, err := os.Stat(filepath.Join(dir, "fresh.txt"))
require.NoError(t, err)
assert.Equal(t, int64(100), info.Size())
}

// TestSandboxTruncateMethodNoCreate covers create=false: the call returns
// os.ErrNotExist for missing files (the truncate -c silent-skip path
// depends on errors.Is matching).
func TestSandboxTruncateMethodNoCreate(t *testing.T) {
dir := t.TempDir()

sb, _, err := New([]string{dir})
require.NoError(t, err)
defer sb.Close()

err = sb.Truncate("missing.txt", dir, 0, false)
assert.ErrorIs(t, err, fs.ErrNotExist)
_, statErr := os.Stat(filepath.Join(dir, "missing.txt"))
assert.True(t, os.IsNotExist(statErr), "no-create must not create missing.txt")
}

// TestSandboxTruncateMethodOutsideAllowedPath verifies that paths outside
// the sandbox are rejected with a permission error before any I/O.
func TestSandboxTruncateMethodOutsideAllowedPath(t *testing.T) {
allowed := t.TempDir()
outside := t.TempDir()
target := filepath.Join(outside, "log.txt")
require.NoError(t, os.WriteFile(target, []byte("untouched"), 0644))

sb, _, err := New([]string{allowed})
require.NoError(t, err)
defer sb.Close()

err = sb.Truncate(target, allowed, 0, true)
assert.ErrorIs(t, err, os.ErrPermission)

got, ferr := os.ReadFile(target)
require.NoError(t, ferr)
assert.Equal(t, "untouched", string(got), "outside file must not be touched")
}

// TestSandboxTruncateMethodNegativeSize verifies that negative sizes are
// rejected with EINVAL.
func TestSandboxTruncateMethodNegativeSize(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "log.txt")
require.NoError(t, os.WriteFile(path, []byte("data"), 0644))

sb, _, err := New([]string{dir})
require.NoError(t, err)
defer sb.Close()

err = sb.Truncate("log.txt", dir, -1, false)
assert.Error(t, err)
got, ferr := os.ReadFile(path)
require.NoError(t, ferr)
assert.Equal(t, "data", string(got), "negative size must not modify the file")
}

// TestSandboxTruncateMethodSymlinkEscapeRejected mirrors
// TestSandboxWriteThroughSymlinkEscapeRejected for the new API: writes
// must not follow a symlink that escapes the os.Root, even via the
// Truncate code path.
func TestSandboxTruncateMethodSymlinkEscapeRejected(t *testing.T) {
allowed := t.TempDir()
outside := t.TempDir()
linkPath := filepath.Join(allowed, "escape")
target := filepath.Join(outside, "target.txt")
require.NoError(t, os.WriteFile(target, []byte("untouched"), 0644))
require.NoError(t, os.Symlink(target, linkPath))

sb, _, err := New([]string{allowed})
require.NoError(t, err)
defer sb.Close()

err = sb.Truncate("escape", allowed, 0, true)
assert.Error(t, err)

got, ferr := os.ReadFile(target)
require.NoError(t, ferr)
assert.Equal(t, "untouched", string(got), "symlink target must not be reachable for writes")
}

func TestSandboxOpenReadStillWorks(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "test.txt"), []byte("data"), 0644))
Expand Down
106 changes: 106 additions & 0 deletions allowedpaths/sandbox_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
package allowedpaths

import (
"fmt"
"os"
"path/filepath"
"syscall"
Expand Down Expand Up @@ -834,3 +835,108 @@ func TestContainerSymlinkRelativeTarget(t *testing.T) {
n, _ := f.Read(buf)
assert.Equal(t, "relative", string(buf[:n]))
}

// TestSandboxTruncateMethodFIFONoReaderDoesNotBlock verifies that
// Sandbox.Truncate rejects a FIFO with no reader without blocking. The
// O_NONBLOCK flag on the open call makes the kernel return ENXIO
// immediately instead of waiting for a connection, which a plain
// O_WRONLY open would do and which the in-builtin ctx.Err() loop cannot
// interrupt.
func TestSandboxTruncateMethodFIFONoReaderDoesNotBlock(t *testing.T) {
dir := t.TempDir()
fifoPath := filepath.Join(dir, "pipe")
require.NoError(t, syscall.Mkfifo(fifoPath, 0644))

sb, _, err := New([]string{dir})
require.NoError(t, err)
defer sb.Close()

done := make(chan error, 1)
go func() {
done <- sb.Truncate("pipe", dir, 0, false)
}()

select {
case err := <-done:
assert.Error(t, err, "FIFO target must be rejected, not silently truncated")
case <-time.After(2 * time.Second):
t.Fatal("Truncate blocked on FIFO without reader — O_NONBLOCK regressed")
}
}

// TestSandboxTruncateMethodCreatesHonourUmask verifies that newly-created
// files use 0666 & ~umask, matching GNU truncate and bash redirect
// semantics. Hard-coding 0644 on the OpenFile call would make the result
// more restrictive than coreutils when umask is permissive (umask 000
// should produce 0666, not 0644).
//
// syscall.Umask is process-global so this test cannot run in parallel
// with other umask-sensitive tests. The defer restores the saved value.
func TestSandboxTruncateMethodCreatesHonourUmask(t *testing.T) {
cases := []struct {
umaskBits int
wantMode os.FileMode
}{
{0o022, 0o644},
{0o000, 0o666},
{0o077, 0o600},
}
for _, tc := range cases {
name := fmt.Sprintf("umask_%03o", tc.umaskBits)
t.Run(name, func(t *testing.T) {
old := syscall.Umask(tc.umaskBits)
defer syscall.Umask(old)

dir := t.TempDir()
sb, _, err := New([]string{dir})
require.NoError(t, err)
defer sb.Close()

require.NoError(t, sb.Truncate("fresh.txt", dir, 0, true))

info, err := os.Stat(filepath.Join(dir, "fresh.txt"))
require.NoError(t, err)
assert.Equal(t, tc.wantMode, info.Mode().Perm(),
"umask %03o should yield mode %03o", tc.umaskBits, tc.wantMode)
})
}
}

// TestSandboxTruncateMethodFIFOWithReaderRejected verifies the post-fd
// fstat guard: when a reader is connected, O_NONBLOCK no longer returns
// ENXIO and the open succeeds, so the in-fd type check is the safety net
// that rejects the FIFO before any ftruncate runs.
//
// This is the regression test for the Stat→Open TOCTOU window: a real
// attacker would swap a regular file for a FIFO between resolution and
// open, but a connected-reader FIFO at open time exercises the same
// branch without needing a race.
func TestSandboxTruncateMethodFIFOWithReaderRejected(t *testing.T) {
dir := t.TempDir()
fifoPath := filepath.Join(dir, "pipe")
require.NoError(t, syscall.Mkfifo(fifoPath, 0644))

// Open the read end so the kernel allows O_WRONLY|O_NONBLOCK opens
// to succeed instead of returning ENXIO.
reader, err := os.OpenFile(fifoPath, os.O_RDONLY|syscall.O_NONBLOCK, 0)
require.NoError(t, err)
defer reader.Close()

sb, _, err := New([]string{dir})
require.NoError(t, err)
defer sb.Close()

done := make(chan error, 1)
go func() {
done <- sb.Truncate("pipe", dir, 0, false)
}()

select {
case err := <-done:
require.Error(t, err, "FIFO with reader must be rejected post-open, not silently truncated")
assert.Contains(t, err.Error(), "not a regular file",
"post-fd fstat guard should be the rejection path here")
case <-time.After(2 * time.Second):
t.Fatal("Truncate blocked on FIFO with reader — post-fd fstat guard regressed")
}
}
1 change: 1 addition & 0 deletions analysis/symbols_allowedpaths.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ var allowedpathsAllowedSymbols = []string{
"strings.Join", // 🟢 joins string slices; pure function, no I/O.
"strings.Split", // 🟢 splits a string by separator; pure function, no I/O.
"syscall.ByHandleFileInformation", // 🟢 Windows file identity structure; pure type for file metadata.
"syscall.EINVAL", // 🟢 "invalid argument" errno constant; pure constant. Used by Sandbox.Truncate to reject negative sizes.
"syscall.EISDIR", // 🟢 "is a directory" errno constant; pure constant.
"syscall.Errno", // 🟢 system call error number type; pure type.
"syscall.GetFileInformationByHandle", // 🟠 Windows API for file identity (vol serial + file index); read-only syscall.
Expand Down
Loading