Skip to content
Merged
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
6 changes: 6 additions & 0 deletions sdk/go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ Consume output via **either** `Output()` **or** `Wait(onData)`, not both — the
content, err := sb.Files().Read(ctx, "/etc/hosts")
err = sb.Files().Write(ctx, "/tmp/hello.txt", []byte("hi"))

// Execute all filesystem operations as a specific sandbox user.
rootFiles := sb.Files().ForUser("root")
content, err = rootFiles.Read(ctx, "/root/hello.txt")
err = rootFiles.Write(ctx, "/root/hello.txt", []byte("hi"))

// Batch write
n, err := sb.Files().WriteFiles(ctx, []cubesandbox.WriteEntry{
{Path: "/tmp/a.txt", Data: []byte("aaa")},
Expand Down Expand Up @@ -160,6 +165,7 @@ for ev := range watcher.Events {

| Method | Description |
|---|---|
| `ForUser(user)` | Return an immutable view that runs all filesystem operations as `user` |
| `Read(ctx, path)` | Download file content via `GET /files` |
| `Write(ctx, path, data)` | Upload via `POST /files` (octet-stream, multipart fallback) |
| `WriteFiles(ctx, entries)` | Batch write, stops on first error, returns count |
Expand Down
22 changes: 21 additions & 1 deletion sdk/go/aligned_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,12 +410,14 @@ func TestCloneKillsSiblingsOnFailure(t *testing.T) {

func TestFilesWriteOctetStreamThenMultipartFallback(t *testing.T) {
var contentTypes []string
var usernames []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/files" {
t.Fatalf("request=%s %s", r.Method, r.URL.Path)
}
ct := r.Header.Get("Content-Type")
contentTypes = append(contentTypes, ct)
usernames = append(usernames, r.URL.Query().Get("username"))
_, _ = io.Copy(io.Discard, r.Body)
if strings.HasPrefix(ct, "application/octet-stream") {
http.Error(w, "use multipart", http.StatusBadRequest) // force fallback
Expand All @@ -432,15 +434,33 @@ func TestFilesWriteOctetStreamThenMultipartFallback(t *testing.T) {
client := NewClient(Config{ProxyNodeIP: host, ProxyPortHTTP: port, SandboxDomain: "cube.test", RequestTimeout: time.Second})
sb := &Sandbox{client: client, SandboxID: "sb-files", EnvdAccessToken: "tok"}

if err := sb.Files().Write(context.Background(), "/tmp/x.txt", []byte("hi")); err != nil {
if err := sb.Files().ForUser("app").Write(context.Background(), "/tmp/x.txt", []byte("hi")); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This switches the only octet-stream→multipart fallback test to the user-scoped path, so the unscoped Files().Write fallback — and its request shape (no username param) — is no longer exercised. TestFilesWithoutUserPreservesUnscopedRequestShape covers only Read and List. Since the PR's stated goal is to preserve unscoped request behavior, it'd be worth keeping (or adding) an unscoped Write assertion that the fallback still works with no username in the query on both attempts.

t.Fatalf("Write: %v", err)
}
if len(contentTypes) != 2 {
t.Fatalf("attempts=%d, want 2 (octet-stream then multipart)", len(contentTypes))
}
if len(usernames) != 2 || usernames[0] != "app" || usernames[1] != "app" {
t.Fatalf("usernames=%v, want app on both upload attempts", usernames)
}
if _, _, err := mime.ParseMediaType(contentTypes[1]); err != nil {
t.Fatalf("multipart content-type=%q: %v", contentTypes[1], err)
}

contentTypes = nil
usernames = nil
if err := sb.Files().Write(context.Background(), "/tmp/unscoped.txt", []byte("hi")); err != nil {
t.Fatalf("unscoped Write: %v", err)
}
if len(contentTypes) != 2 {
t.Fatalf("unscoped attempts=%d, want 2 (octet-stream then multipart)", len(contentTypes))
}
if len(usernames) != 2 || usernames[0] != "" || usernames[1] != "" {
t.Fatalf("unscoped usernames=%v, want empty on both upload attempts", usernames)
}
if _, _, err := mime.ParseMediaType(contentTypes[1]); err != nil {
t.Fatalf("unscoped multipart content-type=%q: %v", contentTypes[1], err)
}
}

func TestCommandsRunSendsUserAuthHeader(t *testing.T) {
Expand Down
68 changes: 48 additions & 20 deletions sdk/go/envd.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,12 @@ func (s *Sandbox) startProcess(ctx context.Context, payload processStartRequest,
return result, nil
}

func (s *Sandbox) readFile(ctx context.Context, path string) (string, error) {
func (s *Sandbox) readFile(ctx context.Context, path string, options ...fileRequestOption) (string, error) {
if err := s.ensureClient(); err != nil {
return "", err
}

query := url.Values{"path": []string{path}}
query := newEnvdFileQuery(path, options...)
req, err := s.newEnvdRequest(ctx, http.MethodGet, "/files", query, nil)
if err != nil {
return "", err
Expand Down Expand Up @@ -149,11 +149,11 @@ func (s *Sandbox) readFile(ctx context.Context, path string) (string, error) {
// writeFile uploads data through envd's POST /files API. It first tries a raw
// octet-stream body and, if the envd version rejects that, retries as a
// multipart upload — mirroring the Python SDK's fallback.
func (s *Sandbox) writeFile(ctx context.Context, path string, data []byte) error {
func (s *Sandbox) writeFile(ctx context.Context, path string, data []byte, options ...fileRequestOption) error {
if err := s.ensureClient(); err != nil {
return err
}
query := url.Values{"path": []string{path}}
query := newEnvdFileQuery(path, options...)

resp, err := s.doEnvdUpload(ctx, query, bytes.NewReader(data), "application/octet-stream")
if err != nil {
Expand Down Expand Up @@ -183,6 +183,15 @@ func (s *Sandbox) writeFile(ctx context.Context, path string, data []byte) error
return nil
}

func newEnvdFileQuery(path string, options ...fileRequestOption) url.Values {
query := url.Values{"path": []string{path}}
opts := resolveFileRequestOptions(options...)
if opts.user != "" {
query.Set("username", opts.user)
}
return query
}

func (s *Sandbox) doEnvdUpload(ctx context.Context, query url.Values, body io.Reader, contentType string) (*http.Response, error) {
req, err := s.newEnvdRequest(ctx, http.MethodPost, "/files", query, body)
if err != nil {
Expand Down Expand Up @@ -243,6 +252,27 @@ func basicAuthUser(user string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"))
}

func setFilesystemRPCHeaders(req *http.Request, options ...fileRequestOption) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Connect-Protocol-Version", connectProtocolVersion)
setFilesystemRPCUser(req, options...)
}

func setFilesystemRPCStreamHeaders(req *http.Request, options ...fileRequestOption) {
req.Header.Set("Content-Type", connectContentType)
req.Header.Set("Connect-Protocol-Version", connectProtocolVersion)
setFilesystemRPCUser(req, options...)
}

func setFilesystemRPCUser(req *http.Request, options ...fileRequestOption) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RPC user propagation is unverified in CI. The unit tests only assert that the Authorization: Basic <user>: header is sent; they cannot confirm envd honors it on /filesystem.Filesystem/*. The test that would (TestIntegrationFilesForUserIsolation) is behind //go:build integration, skips in short mode, and does not run in the sdk-test-check workflow (hermetic unit tests only). The base Go and node SDKs never sent Basic auth on these RPC endpoints, so if envd does not read it, ForUser silently no-ops for List/Stat/Remove/Rename/MakeDir/WatchDir. Worth confirming against the envd contract and ideally adding a CI-runnable check.

opts := resolveFileRequestOptions(options...)
if opts.user != "" {
// Preserve the legacy unscoped request shape. An explicit ForUser("root")
// is intentionally different and sends root through Basic authentication.
req.SetBasicAuth(opts.user, "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

req.SetBasicAuth unconditionally overwrites any existing Authorization header. Nothing sets it before this point today (newEnvdRequest only adds X-Access-Token + traffic tokens), so this isn't a live bug — but it's fragile and duplicates the existing basicAuthUser helper, which builds exactly this value. Consider req.Header.Set("Authorization", basicAuthUser(opts.user)) inside the guard instead.

Also worth a comment: this guard is deliberate, but it makes the wire format asymmetric with startProcess (which sends Basic root: for an empty user via basicAuthUser("") → defaultEnvdUser). Here, unscoped requests emit no Authorization header while ForUser("root") emits Basic root:. That preserves legacy unscoped behavior, but it's subtle enough to warrant a one-line note so a future reader doesn't "fix" the guard.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two nits on the new envd Basic-auth construction:

  1. This builds the same header as the existing basicAuthUser helper (Basic base64(user + ":")) but bypasses it, so the envd Basic-header format now lives in two places. Consider req.Header.Set("Authorization", basicAuthUser(opts.user)) to keep a single source of truth — including the defaultEnvdUser fallback semantics.
  2. Because this is guarded by opts.user != "", an empty user sends no Authorization header, whereas basicAuthUser("") would send Basic cm9vdDo= (root). So on the RPC path ForUser("root") is wire-different from unscoped. The comment documents the intent, but the README's ForUser("root") example (used to read /root/hello.txt) could lead users to think it's a no-op relative to the default — worth surfacing to SDK users.

}
}

func parseProcessStartStream(r io.Reader) (*processStartResult, error) {
var result processStartResult
var stdout strings.Builder
Expand Down Expand Up @@ -322,7 +352,7 @@ func decodeProcessBytes(value string) (string, error) {
return string(raw), nil
}

func (s *Sandbox) filesystemRPC(ctx context.Context, method string, reqBody any) ([]byte, int, error) {
func (s *Sandbox) filesystemRPC(ctx context.Context, method string, reqBody any, options ...fileRequestOption) ([]byte, int, error) {
if err := s.ensureClient(); err != nil {
return nil, 0, err
}
Expand All @@ -334,8 +364,7 @@ func (s *Sandbox) filesystemRPC(ctx context.Context, method string, reqBody any)
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Connect-Protocol-Version", connectProtocolVersion)
setFilesystemRPCHeaders(req, options...)

resp, err := s.client.dataHTTP.Do(req)
if err != nil {
Expand All @@ -349,8 +378,8 @@ func (s *Sandbox) filesystemRPC(ctx context.Context, method string, reqBody any)
return body, resp.StatusCode, nil
}

func (s *Sandbox) listDir(ctx context.Context, path string) ([]FileEntry, error) {
body, status, err := s.filesystemRPC(ctx, "ListDir", map[string]string{"path": path})
func (s *Sandbox) listDir(ctx context.Context, path string, options ...fileRequestOption) ([]FileEntry, error) {
body, status, err := s.filesystemRPC(ctx, "ListDir", map[string]string{"path": path}, options...)
if err != nil {
return nil, err
}
Expand All @@ -369,8 +398,8 @@ func (s *Sandbox) listDir(ctx context.Context, path string) ([]FileEntry, error)
return result.Entries, nil
}

func (s *Sandbox) statFile(ctx context.Context, path string) (*FileEntry, error) {
body, status, err := s.filesystemRPC(ctx, "Stat", map[string]string{"path": path})
func (s *Sandbox) statFile(ctx context.Context, path string, options ...fileRequestOption) (*FileEntry, error) {
body, status, err := s.filesystemRPC(ctx, "Stat", map[string]string{"path": path}, options...)
if err != nil {
return nil, err
}
Expand All @@ -389,8 +418,8 @@ func (s *Sandbox) statFile(ctx context.Context, path string) (*FileEntry, error)
return &result.Entry, nil
}

func (s *Sandbox) removeFile(ctx context.Context, path string) error {
body, status, err := s.filesystemRPC(ctx, "Remove", map[string]string{"path": path})
func (s *Sandbox) removeFile(ctx context.Context, path string, options ...fileRequestOption) error {
body, status, err := s.filesystemRPC(ctx, "Remove", map[string]string{"path": path}, options...)
if err != nil {
return err
}
Expand All @@ -400,8 +429,8 @@ func (s *Sandbox) removeFile(ctx context.Context, path string) error {
return nil
}

func (s *Sandbox) moveFile(ctx context.Context, source, destination string) (*FileEntry, error) {
body, status, err := s.filesystemRPC(ctx, "Move", map[string]string{"source": source, "destination": destination})
func (s *Sandbox) moveFile(ctx context.Context, source, destination string, options ...fileRequestOption) (*FileEntry, error) {
body, status, err := s.filesystemRPC(ctx, "Move", map[string]string{"source": source, "destination": destination}, options...)
if err != nil {
return nil, err
}
Expand All @@ -417,8 +446,8 @@ func (s *Sandbox) moveFile(ctx context.Context, source, destination string) (*Fi
return &result.Entry, nil
}

func (s *Sandbox) makeDirFile(ctx context.Context, path string) (*FileEntry, error) {
body, status, err := s.filesystemRPC(ctx, "MakeDir", map[string]string{"path": path})
func (s *Sandbox) makeDirFile(ctx context.Context, path string, options ...fileRequestOption) (*FileEntry, error) {
body, status, err := s.filesystemRPC(ctx, "MakeDir", map[string]string{"path": path}, options...)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -474,7 +503,7 @@ type watchDirFrame struct {
Keepalive *struct{} `json:"keepalive,omitempty"`
}

func (s *Sandbox) watchDir(ctx context.Context, path string) (*Watcher, error) {
func (s *Sandbox) watchDir(ctx context.Context, path string, options ...fileRequestOption) (*Watcher, error) {
if err := s.ensureClient(); err != nil {
return nil, err
}
Expand All @@ -490,8 +519,7 @@ func (s *Sandbox) watchDir(ctx context.Context, path string) (*Watcher, error) {
cancel()
return nil, err
}
req.Header.Set("Content-Type", connectContentType)
req.Header.Set("Connect-Protocol-Version", connectProtocolVersion)
setFilesystemRPCStreamHeaders(req, options...)

resp, err := s.client.dataHTTP.Do(req)
if err != nil {
Expand Down
67 changes: 51 additions & 16 deletions sdk/go/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,38 +13,73 @@ type Files struct {
reader fileReader
writer fileWriter
filer fileFiler
user string
}

type fileRequestOptions struct {
user string
}

type fileRequestOption func(*fileRequestOptions)

func withUser(user string) fileRequestOption {
return func(options *fileRequestOptions) {
options.user = user
}
}

func resolveFileRequestOptions(options ...fileRequestOption) fileRequestOptions {
var resolved fileRequestOptions
for _, option := range options {
if option != nil {
option(&resolved)
}
}
return resolved
}

type fileReader interface {
readFile(context.Context, string) (string, error)
readFile(context.Context, string, ...fileRequestOption) (string, error)
}

type fileWriter interface {
writeFile(context.Context, string, []byte) error
writeFile(context.Context, string, []byte, ...fileRequestOption) error
}

type fileFiler interface {
listDir(context.Context, string) ([]FileEntry, error)
statFile(context.Context, string) (*FileEntry, error)
removeFile(context.Context, string) error
moveFile(context.Context, string, string) (*FileEntry, error)
makeDirFile(context.Context, string) (*FileEntry, error)
watchDir(context.Context, string) (*Watcher, error)
listDir(context.Context, string, ...fileRequestOption) ([]FileEntry, error)
statFile(context.Context, string, ...fileRequestOption) (*FileEntry, error)
removeFile(context.Context, string, ...fileRequestOption) error
moveFile(context.Context, string, string, ...fileRequestOption) (*FileEntry, error)
makeDirFile(context.Context, string, ...fileRequestOption) (*FileEntry, error)
watchDir(context.Context, string, ...fileRequestOption) (*Watcher, error)
}

// ForUser returns an immutable filesystem view that executes every operation

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider documenting how the user is actually propagated, since it differs by transport: HTTP file operations (Read/Write/WriteFiles) send ?username=<user> on /files, while the filesystem RPC operations (List/Stat/Remove/Rename/MakeDir/WatchDir) send Authorization: Basic <user>:. The view is only consistent if envd honors both mechanisms. Also worth noting: ForUser("root") is wire-different from the unscoped default on the RPC path (explicit Basic root vs no header), even though both are semantically "root".

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation accuracy: "executes every operation as user" overstates the isolation this view provides. Per the integration test added in this PR (TestIntegrationFilesForUserIsolation), Files.Read/Files.Stat are "privileged envd management operations" — the selected user controls path expansion and ownership, but these endpoints do not switch the envd process UID, so they are not a POSIX permission boundary. A caller doing sb.Files().ForUser("nobody").Read(rootOnlyPath) should not expect a permission denial (the test verifies the real boundary via Commands/cat, deliberately not via Files.Read).

Suggest a caveat such as: "runs operations in the filesystem context of user (ownership and path expansion). Note the envd file HTTP/RPC endpoints are privileged and do not enforce POSIX permissions — use Commands for a permission boundary." The same applies to the README's "Execute all filesystem operations as a specific sandbox user" line.

// as user. The returned view shares the underlying sandbox connection with f.
// An empty user restores the unscoped behavior used by Sandbox.Files.
func (f *Files) ForUser(user string) *Files {
if f == nil {
return nil
}
scoped := *f
scoped.user = user

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs overstate the isolation guarantee. The doc (and the README example) says ForUser returns a view that "executes every operation as user", but the integration test added in this PR explicitly notes that Files.Read/Files.Stat are privileged envd management operations that do not switch the envd process UID — the user only controls path expansion and ownership. So ForUser("nobody").Read("/root/private") may succeed where a POSIX nobody would be denied, and callers relying on ForUser for access control would get a false sense of isolation. Consider qualifying the doc/README (e.g. "authorizes operations as user; Read/Stat do not switch the envd process UID").

return &scoped
}

func (f *Files) Read(ctx context.Context, path string) (string, error) {
if f == nil || f.reader == nil {
return "", fmt.Errorf("files is not attached to a sandbox")
}
return f.reader.readFile(ctx, path)
return f.reader.readFile(ctx, path, withUser(f.user))
}

// Write uploads data to path through envd's HTTP file API.
func (f *Files) Write(ctx context.Context, path string, data []byte) error {
if f == nil || f.writer == nil {
return fmt.Errorf("files is not attached to a sandbox")
}
return f.writer.writeFile(ctx, path, data)
return f.writer.writeFile(ctx, path, data, withUser(f.user))
}

// WriteFiles uploads multiple files. It stops at the first error and returns
Expand All @@ -66,15 +101,15 @@ func (f *Files) List(ctx context.Context, path string) ([]FileEntry, error) {
if f == nil || f.filer == nil {
return nil, fmt.Errorf("files is not attached to a sandbox")
}
return f.filer.listDir(ctx, path)
return f.filer.listDir(ctx, path, withUser(f.user))
}

// Stat returns metadata for a single file or directory.
func (f *Files) Stat(ctx context.Context, path string) (*FileEntry, error) {
if f == nil || f.filer == nil {
return nil, fmt.Errorf("files is not attached to a sandbox")
}
return f.filer.statFile(ctx, path)
return f.filer.statFile(ctx, path, withUser(f.user))
}

// Exists returns true if the path exists inside the sandbox.
Expand All @@ -95,23 +130,23 @@ func (f *Files) Remove(ctx context.Context, path string) error {
if f == nil || f.filer == nil {
return fmt.Errorf("files is not attached to a sandbox")
}
return f.filer.removeFile(ctx, path)
return f.filer.removeFile(ctx, path, withUser(f.user))
}

// Rename moves or renames a file or directory inside the sandbox.
func (f *Files) Rename(ctx context.Context, oldPath, newPath string) (*FileEntry, error) {
if f == nil || f.filer == nil {
return nil, fmt.Errorf("files is not attached to a sandbox")
}
return f.filer.moveFile(ctx, oldPath, newPath)
return f.filer.moveFile(ctx, oldPath, newPath, withUser(f.user))
}

// MakeDir creates a directory inside the sandbox.
func (f *Files) MakeDir(ctx context.Context, path string) (*FileEntry, error) {
if f == nil || f.filer == nil {
return nil, fmt.Errorf("files is not attached to a sandbox")
}
return f.filer.makeDirFile(ctx, path)
return f.filer.makeDirFile(ctx, path, withUser(f.user))
}

// WatchDir watches a directory for filesystem changes. The returned Watcher
Expand All @@ -120,5 +155,5 @@ func (f *Files) WatchDir(ctx context.Context, path string) (*Watcher, error) {
if f == nil || f.filer == nil {
return nil, fmt.Errorf("files is not attached to a sandbox")
}
return f.filer.watchDir(ctx, path)
return f.filer.watchDir(ctx, path, withUser(f.user))
}
Loading
Loading