fs

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 29 Imported by: 0

Documentation

Overview

Package fs implements FileService: read, write, edit, list, stat, glob, grep, and the three path-management RPCs — make directory, remove and move.

Confinement

Every path this package touches goes through the jail.Jail handed to New, and that jail is the only thing deciding what is reachable. It is a single injected dependency rather than a flag threaded through the handlers: an agent with no confinement is built with jail.Unconfined, which normalises paths and permits all of them, so no handler here asks whether a jail exists and none of them can be wrong about the answer.

Whether the jail confines anything is the daemon's decision, not this package's. An agent with ExecService enabled — the default — hands out an unconfined jail, because a caller who can run `sh -c 'echo x > /etc/passwd'` reaches any path without FileService, and a path check that stops nobody while looking like a security control is worse than no check. See internal/agent.jailFor and docs/security.md. Nothing in this package claims confinement it does not have: an unconfined jail returns no rejection, so no handler here can report one.

A path-management RPC acts on the path itself rather than on what it points at, so RemovePath and MovePath resolve only the *parent* through the jail and leave the last component exactly as the caller wrote it. Removing a symlink unlinks the symlink; it never follows one to delete the file at the other end, which is the classic way a delete leaves its confinement.

Writes

Every write in this package — WriteFile and EditFile alike — goes through [atomicFile]: a temp file in the same directory as the target, fsynced, then renamed over it. The temp file is a sibling rather than a file in the system temp directory because rename is only atomic within a filesystem; across one, it is a copy, and a copy interrupted halfway is exactly the truncated file this exists to prevent. An interrupted transfer removes the temp file and leaves the original untouched.

Committing by rename is also why a write resolves every symlink on the path it commits to, which is the exact opposite of what the path-management RPCs do above. A rename over a symlink replaces the link with a regular file, so a write that did not follow the link would unlink it, write a new file where it stood, and leave the file the caller meant to change untouched. Resolving the parents matters for a second reason: two spellings of one file have to take one path lock, or two concurrent edits lose each other. A confined jail resolves everything already; the unconfined one does not, and it is the default. See Service.writeTarget.

What a response may not contain

Every string a response carries is a proto3 string, and marshalling one that is not valid UTF-8 fails the call rather than the field. Two places produce bytes this service did not choose: a grep line, where the binary check has only seen the head of the file, and a diff, where a line is sliced to fit. Both are made valid before they are sent.

Files this package will not open

Only regular files and directories. A named pipe blocks inside open(2) until a writer appears, with no deadline and no way for a cancelled request to interrupt it, so a single RPC naming one would strand its handler goroutine for the life of the process — and the pipe need only exist somewhere in a tree the caller can name. Devices and sockets are refused with it, here and in the walk that Glob and Grep share.

Streaming

ReadFile and WriteFile stream in bounded chunks and never hold a whole file in memory; a 100 MB transfer costs a chunk buffer, not 100 MB of heap. EditFile is the exception and has to be — an exact-match replacement needs the whole file — so it refuses files over DefaultMaxEditBytes.

Index

Constants

View Source
const (
	DefaultChunkBytes          = 64 * 1024
	DefaultReadLines           = 2000
	DefaultMaxReadBytes        = 8 * 1024 * 1024
	DefaultLineCountLimitBytes = 32 * 1024 * 1024
	DefaultMaxEditBytes        = 16 * 1024 * 1024
	DefaultListEntries         = 1000
	DefaultGlobResults         = 1000
	DefaultMaxGlobCandidates   = 100_000
	DefaultGrepMatches         = 500
	DefaultMaxGrepLineBytes    = 256 * 1024
)

Defaults for Limits. They are exported because #24 renders what these produce and needs to describe the caps in the tool schema.

View Source
const DefaultDirMode fs.FileMode = 0o755

DefaultDirMode is applied to directories created by create_parents.

View Source
const DefaultFileMode fs.FileMode = 0o644

DefaultFileMode is applied to a file this service creates when the caller names no mode. It is the umask-independent equivalent of what a shell redirect produces.

Variables

View Source
var ErrBadPattern = errors.New("fs: bad glob pattern")

ErrBadPattern reports a glob the agent cannot compile.

Functions

func New

func New(deps agent.Deps) (agent.Service, error)

New builds the file service. It satisfies agent.Factory.

Types

type Limits

type Limits struct {
	// ChunkBytes is the payload size of one ReadFile chunk.
	ChunkBytes int

	// DefaultReadLines is the line window ReadFile serves when the caller names
	// none.
	DefaultReadLines uint64

	// DefaultMaxReadBytes caps a ReadFile response when the caller names no
	// max_bytes. It sits under the 32 MiB gRPC message cap with room to spare;
	// the cap is per-message, but a caller collecting a stream into one buffer
	// is the common case and this keeps that honest.
	DefaultMaxReadBytes uint64

	// LineCountLimitBytes is the file size past which ReadFile stops counting
	// lines. Counting means reading every byte, and reading a gigabyte to
	// answer a windowed read is exactly what the caller asked not to do. Past
	// this size total_lines reports how far the count got and total_lines_exact
	// is false.
	LineCountLimitBytes int64

	// MaxEditBytes is the largest file EditFile will load. Exact-match
	// replacement needs the whole file, so this is a real ceiling rather than a
	// streaming threshold.
	MaxEditBytes int64

	// DefaultListEntries caps ListDirectory when the caller names no limit.
	DefaultListEntries int

	// DefaultGlobResults caps Glob when the caller names no limit.
	DefaultGlobResults int

	// MaxGlobCandidates bounds the paths Glob holds while walking. Glob sorts
	// by modification time, so it cannot stop at the first N matches the way
	// Grep can — the newest match may be the last file the walk reaches. This
	// bounds the memory that costs; hitting it is reported as truncation.
	MaxGlobCandidates int

	// DefaultGrepMatches caps Grep when the caller names no max_matches.
	DefaultGrepMatches uint64

	// MaxGrepLineBytes is the longest line Grep will read. A file with a longer
	// one is abandoned mid-scan rather than buffered: it is minified or packed
	// data, and matching a megabyte-long line usefully is not a thing.
	MaxGrepLineBytes int
}

Limits bound what one RPC may cost the agent. Every field has a default; the zero value is the default set, which is what the daemon uses.

type Service

type Service struct {
	sandboxdv1.UnimplementedFileServiceServer
	// contains filtered or unexported fields
}

Service implements sandboxd.v1.FileService: every RPC the proto declares.

func NewService

func NewService(j *jail.Jail, log *slog.Logger, limits Limits) *Service

NewService builds a file service directly, for a caller that has a jail and a logger but no agent.Deps.

func (*Service) EditFile

EditFile replaces an exact string in a file.

The contract is deliberately the one the agent's built-in edit tool has, because a model that already knows that tool must not have to learn a second set of rules to use this one:

  • The match is exact. Whitespace and indentation are significant, and nothing is normalised on either side.
  • Without replace_all the edit fails unless old_string occurs exactly once. An ambiguous match is the failure mode this rule exists to prevent: with two candidates, a replacement picks one, and the caller finds out which by reading the diff afterwards. The error names the count so the caller knows to add surrounding context rather than guess.
  • old_string == new_string is an error. A no-op that reports success is a caller believing it changed something.

Every failure leaves the file exactly as it was. The replacement is computed in memory and committed through the same sibling-temp-file-and-rename path as WriteFile, so there is no window in which a rejected edit has partly happened.

Line endings survive. The replacement is a byte-exact substring swap, so the terminators outside it are untouched, and a new_string whose endings disagree with the file's is refused rather than mixed in — see checkNewLineEndings.

func (*Service) Glob

Glob finds files matching a pattern, newest first.

The pattern is anchored at root: "*.go" matches the .go files directly in it and does not recurse, and "**/*.go" matches at any depth including the root. Anchoring is what makes the two spellings mean different things, and a walk that could never match a subtree — "src/**/*.go" under "docs" — skips it rather than reading it a file at a time.

Results are files. Directories are what ListDirectory is for, and including them here would push real matches past the cap on any pattern ending in "*".

Ordering, and why this one does not stop early

Results are sorted by modification time, newest first, because the file someone is looking for is almost always the one they last touched. That ordering cannot be produced by stopping at the first `limit` matches: the newest file may be the last one the walk reaches. So Glob walks — bounded by Limits.MaxGlobCandidates, and reporting truncation when it hits that bound — and sorts what it found. Grep, whose results have no such global ordering, is the one that stops the walk the moment its cap is reached.

Ties are broken by path so that two files written in the same filesystem timestamp tick come back in the same order every time.

func (*Service) Grep

Grep searches a tree and streams matches as it finds them.

It runs here rather than being composed from ListDirectory and ReadFile because the alternative is streaming a tree across the network to search it on the other side. Two properties follow from that, and both are contractual:

  • Matches are sent as they are found. The first match reaches the caller while the walk is still going, so a search over a large tree is useful before it is finished.
  • max_matches stops the walk. It is a bound on work, not a filter applied to a finished search: the summary's files_searched reports how few files were opened, and on a large tree that number is the difference between a search that costs milliseconds and one that costs the whole disk.

Binary files are skipped rather than matched, since a regex over compiled code produces matches that mean nothing and lines that render as noise. One implementation, no ripgrep: shelling out to a binary that is on some fleet hosts and not others gives two different search semantics behind one tool name, and the caller cannot tell which one answered.

func (*Service) ListDirectory

ListDirectory lists a directory, optionally recursively.

Symlinked directories are reported but never descended into, here and in Glob and Grep. A tree whose links form a cycle therefore terminates, and a link pointing out of the jail cannot smuggle an outside subtree into the listing.

Entry paths are absolute, rooted at the resolved directory. The cap stops the walk rather than trimming a completed one, so a recursive listing of a million-file tree costs the cap, not the tree — which means the number of entries omitted is not known and is reported as zero against a true truncated flag.

func (*Service) MakeDirectory

MakeDirectory creates a directory.

An existing directory is not an error — it is the state the caller asked for, and reporting created:false says everything a caller needs to distinguish the two. An existing *file* at that path is an error, because the caller asked for a directory and there is not one.

create_parents means what it means in WriteFile: missing parents are created at 0755, and without it a missing parent is a NotFound rather than a silent mkdir -p. The named directory itself takes mode, or 0755, applied after creation so the daemon's umask cannot narrow what the caller asked for.

func (*Service) MovePath

MovePath renames a file, a symlink, or a directory.

Both endpoints go through the jail, not just the destination: a move whose source is outside the roots is a read out of them, and one whose destination is outside is a write out of them.

Like RemovePath, the last component of each path is left unresolved, so moving a symlink moves the link rather than dragging what it points at to a new name.

destination is the full path to move to, never a directory to move into. The difference is the one that silently does the wrong thing when guessed, so it is stated and an existing directory at the destination is refused.

func (*Service) ReadFile

ReadFile streams a file: metadata, then content chunks, then a result.

Nothing here holds more than one chunk. A 100 MB file costs a chunk buffer and the garbage collector's patience, never 100 MB of heap — which is the point of it being a stream rather than a response with a bytes field.

The metadata message is sent before anything can fail on the content, so a caller that asked for a binary file as text learns its size, mode and is_binary flag along with the refusal rather than instead of it.

func (*Service) Register

func (s *Service) Register(r grpc.ServiceRegistrar)

Register attaches FileService to the daemon's gRPC server.

func (*Service) RemovePath

RemovePath removes a file, a symlink, or a directory.

Three things make this the dangerous RPC, and each is handled explicitly rather than left to the filesystem's defaults:

  • Recursion is opt-in. Without it a non-empty directory is a FailedPrecondition naming the flag, not a silent recursive delete. The emptiness is checked rather than inferred from an errno, because the errno for it is not portable and the message would be worse.
  • A jail root cannot be removed. Removing the root would destroy the confinement while staying inside it, which is the one deletion the jail cannot be expected to survive. An unconfined agent has no roots and so no such refusal — there is nothing to protect and pretending otherwise would be the decoration this repo's exec/jail decision exists to remove.
  • A symlink is unlinked, never followed. Resolving the final component first — which every content RPC here does — would delete what the link points at, and that is the classic way a delete leaves the jail: a link inside the roots aimed anywhere at all. So containment is checked on the resolved *parent* and the last component is left exactly as the caller wrote it.

func (*Service) StatPath

StatPath reports whether a path exists, and what it is.

A missing path is exists:false, not an error: "does this exist" is the question, and answering it with NotFound would make the caller parse an error to learn a boolean.

The metadata describes the path itself, with symlinks reported rather than followed — is_symlink and symlink_target say what it points at, and a caller that wants the target's own size stats the target. metadata.path is the path the caller named, made absolute; it is not the resolved path, so that the answer is about the thing that was asked about.

func (*Service) WriteFile

WriteFile receives a header and then content, and renames the result over the target.

Nothing is visible at the target path until the last byte has been received and fsynced. A stream that dies halfway — a cancelled RPC, a killed client, a disk that fills — removes the temp file and leaves the original exactly as it was. That is the guarantee the sibling temp file and the rename buy, and it is why this is a client stream rather than a request with a bytes field.

The whole call holds the path's lock, so a write and an edit racing on one file serialise rather than losing each other's work. Two writers to different paths never meet.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL