diff --git a/sdk/go/README.md b/sdk/go/README.md index 2c364c7c7..06f4fa925 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -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")}, @@ -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 | diff --git a/sdk/go/aligned_test.go b/sdk/go/aligned_test.go index fd7c0c6ce..824e9780d 100644 --- a/sdk/go/aligned_test.go +++ b/sdk/go/aligned_test.go @@ -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 @@ -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 { 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) { diff --git a/sdk/go/envd.go b/sdk/go/envd.go index aa0f4480e..387ef42f6 100644 --- a/sdk/go/envd.go +++ b/sdk/go/envd.go @@ -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 @@ -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 { @@ -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 { @@ -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) { + 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, "") + } +} + func parseProcessStartStream(r io.Reader) (*processStartResult, error) { var result processStartResult var stdout strings.Builder @@ -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 } @@ -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 { @@ -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 } @@ -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 } @@ -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 } @@ -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 } @@ -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 } @@ -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 } @@ -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 { diff --git a/sdk/go/files.go b/sdk/go/files.go index ce8f71bb7..fb64cee11 100644 --- a/sdk/go/files.go +++ b/sdk/go/files.go @@ -13,30 +13,65 @@ 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 +// 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 + 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. @@ -44,7 +79,7 @@ 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 @@ -66,7 +101,7 @@ 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. @@ -74,7 +109,7 @@ 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. @@ -95,7 +130,7 @@ 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. @@ -103,7 +138,7 @@ func (f *Files) Rename(ctx context.Context, oldPath, newPath string) (*FileEntry 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. @@ -111,7 +146,7 @@ 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 @@ -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)) } diff --git a/sdk/go/files_user_test.go b/sdk/go/files_user_test.go new file mode 100644 index 000000000..ebef92912 --- /dev/null +++ b/sdk/go/files_user_test.go @@ -0,0 +1,244 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cubesandbox + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func TestFileRequestOptions(t *testing.T) { + if got := resolveFileRequestOptions(); got.user != "" { + t.Fatalf("default user=%q, want empty", got.user) + } + if got := resolveFileRequestOptions(withUser("root"), nil, withUser("app")); got.user != "app" { + t.Fatalf("resolved user=%q, want last option to win", got.user) + } +} + +func TestFilesForUserReturnsImmutableView(t *testing.T) { + reader := &fakeFileReader{content: "content"} + files := &Files{reader: reader} + + rootFiles := files.ForUser("root") + appFiles := rootFiles.ForUser("app") + + if files == rootFiles || rootFiles == appFiles { + t.Fatal("ForUser must return a new filesystem view") + } + if files.user != "" || rootFiles.user != "root" || appFiles.user != "app" { + t.Fatalf("users=%q/%q/%q", files.user, rootFiles.user, appFiles.user) + } + + if _, err := appFiles.Read(context.Background(), "/tmp/file"); err != nil { + t.Fatalf("Read: %v", err) + } + if reader.path != "/tmp/file" || reader.user != "app" { + t.Fatalf("read path/user=%q/%q", reader.path, reader.user) + } + + if (*Files)(nil).ForUser("root") != nil { + t.Fatal("ForUser on a nil Files must return nil") + } +} + +type capturedFileRequest struct { + method string + path string + contentType string + username string + authorization string + accessToken string + e2bToken string + cubeToken string + body string +} + +func TestFilesForUserPropagatesIdentityToEveryTransport(t *testing.T) { + var mu sync.Mutex + var requests []capturedFileRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + requests = append(requests, capturedFileRequest{ + method: r.Method, + path: r.URL.Path, + contentType: r.Header.Get("Content-Type"), + username: r.URL.Query().Get("username"), + authorization: r.Header.Get("Authorization"), + accessToken: r.Header.Get("X-Access-Token"), + e2bToken: r.Header.Get("e2b-traffic-access-token"), + cubeToken: r.Header.Get("cube-traffic-access-token"), + body: string(body), + }) + mu.Unlock() + + switch r.URL.Path { + case "/files": + if r.Method == http.MethodGet { + fmt.Fprint(w, "content") + return + } + w.WriteHeader(http.StatusOK) + case "/filesystem.Filesystem/ListDir": + fmt.Fprint(w, `{"entries":[]}`) + case "/filesystem.Filesystem/Stat": + fmt.Fprint(w, `{"entry":{"name":"file","type":"FILE_TYPE_FILE","path":"/tmp/file","size":"1","mode":420}}`) + case "/filesystem.Filesystem/Remove": + fmt.Fprint(w, `{}`) + case "/filesystem.Filesystem/Move": + fmt.Fprint(w, `{"entry":{"name":"renamed","type":"FILE_TYPE_FILE","path":"/tmp/renamed","size":"1","mode":420}}`) + case "/filesystem.Filesystem/MakeDir": + fmt.Fprint(w, `{"entry":{"name":"dir","type":"FILE_TYPE_DIRECTORY","path":"/tmp/dir","size":"0","mode":493}}`) + case "/filesystem.Filesystem/WatchDir": + w.Header().Set("Content-Type", connectContentType) + _, _ = w.Write(connectFrame(0, []byte(`{"start":{}}`))) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + host, port := serverHostPort(t, server.URL) + client := NewClient(Config{ + ProxyNodeIP: host, + ProxyPortHTTP: port, + SandboxDomain: "cube.test", + RequestTimeout: time.Second, + }) + sb := &Sandbox{ + client: client, + SandboxID: "sb-files-user", + EnvdAccessToken: "envd-token", + TrafficAccessToken: "traffic-token", + } + files := sb.Files().ForUser("app") + ctx := context.Background() + + if _, err := files.Read(ctx, "/tmp/file"); err != nil { + t.Fatalf("Read: %v", err) + } + if err := files.Write(ctx, "/tmp/file", []byte("content")); err != nil { + t.Fatalf("Write: %v", err) + } + if n, err := files.WriteFiles(ctx, []WriteEntry{ + {Path: "/tmp/a", Data: []byte("a")}, + {Path: "/tmp/b", Data: []byte("b")}, + }); err != nil || n != 2 { + t.Fatalf("WriteFiles: n=%d err=%v", n, err) + } + if _, err := files.List(ctx, "/tmp"); err != nil { + t.Fatalf("List: %v", err) + } + if _, err := files.Stat(ctx, "/tmp/file"); err != nil { + t.Fatalf("Stat: %v", err) + } + if exists, err := files.Exists(ctx, "/tmp/file"); err != nil || !exists { + t.Fatalf("Exists: exists=%v err=%v", exists, err) + } + if err := files.Remove(ctx, "/tmp/file"); err != nil { + t.Fatalf("Remove: %v", err) + } + if _, err := files.Rename(ctx, "/tmp/file", "/tmp/renamed"); err != nil { + t.Fatalf("Rename: %v", err) + } + if _, err := files.MakeDir(ctx, "/tmp/dir"); err != nil { + t.Fatalf("MakeDir: %v", err) + } + watcher, err := files.WatchDir(ctx, "/tmp") + if err != nil { + t.Fatalf("WatchDir: %v", err) + } + for range watcher.Events { + } + if err := watcher.Close(); err != nil { + t.Fatalf("close watcher: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(requests) != 11 { + t.Fatalf("request count=%d, want 11: %#v", len(requests), requests) + } + for _, request := range requests { + if request.accessToken != "envd-token" || request.e2bToken != "traffic-token" || request.cubeToken != "traffic-token" { + t.Errorf("%s tokens=%q/%q/%q", request.path, request.accessToken, request.e2bToken, request.cubeToken) + } + if request.path == "/files" { + if request.username != "app" { + t.Errorf("%s %s username=%q, want app", request.method, request.path, request.username) + } + if request.authorization != "" { + t.Errorf("%s %s Authorization=%q, want empty", request.method, request.path, request.authorization) + } + continue + } + if request.username != "" { + t.Errorf("%s username=%q, want empty", request.path, request.username) + } + wantContentType := "application/json" + if request.path == "/filesystem.Filesystem/WatchDir" { + wantContentType = connectContentType + } + if request.contentType != wantContentType { + t.Errorf("%s Content-Type=%q, want %q", request.path, request.contentType, wantContentType) + } + if request.authorization != basicAuthUser("app") { + t.Errorf("%s Authorization=%q, want %q", request.path, request.authorization, basicAuthUser("app")) + } + if strings.Contains(request.body, `"user"`) || strings.Contains(request.body, `"username"`) { + t.Errorf("%s request body contains user identity: %s", request.path, request.body) + } + } +} + +func TestFilesWithoutUserPreservesUnscopedRequestShape(t *testing.T) { + var mu sync.Mutex + var requests []capturedFileRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, capturedFileRequest{ + path: r.URL.Path, + username: r.URL.Query().Get("username"), + authorization: r.Header.Get("Authorization"), + }) + mu.Unlock() + if r.URL.Path == "/files" { + fmt.Fprint(w, "content") + return + } + fmt.Fprint(w, `{"entries":[]}`) + })) + defer server.Close() + + host, port := serverHostPort(t, server.URL) + client := NewClient(Config{ProxyNodeIP: host, ProxyPortHTTP: port, SandboxDomain: "cube.test", RequestTimeout: time.Second}) + sb := &Sandbox{client: client, SandboxID: "sb-files-unscoped"} + + if _, err := sb.Files().Read(context.Background(), "/tmp/file"); err != nil { + t.Fatalf("Read: %v", err) + } + if _, err := sb.Files().List(context.Background(), "/tmp"); err != nil { + t.Fatalf("List: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(requests) != 2 { + t.Fatalf("request count=%d, want 2", len(requests)) + } + for _, request := range requests { + if request.username != "" || request.authorization != "" { + t.Errorf("%s username/Authorization=%q/%q, want empty", request.path, request.username, request.authorization) + } + } +} diff --git a/sdk/go/integration_test.go b/sdk/go/integration_test.go index 57686887b..a6479e82b 100644 --- a/sdk/go/integration_test.go +++ b/sdk/go/integration_test.go @@ -53,6 +53,111 @@ func TestIntegrationHealthTemplateAndList(t *testing.T) { } } +func TestIntegrationFilesForUserIsolation(t *testing.T) { + cfg := integrationConfig(t) + client := NewClient(cfg) + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + + sb := createIntegrationSandbox(t, ctx, client, CreateOptions{ + Timeout: DurationPtr(2 * time.Minute), + Metadata: map[string]string{ + "sdk": "go", + "scenario": "integration-files-user-isolation", + }, + }) + + rootFiles := sb.Files().ForUser("root") + nobodyFiles := sb.Files().ForUser("nobody") + + // Prove that the secondary user exists and can use both filesystem + // transports before asserting that access to a root-only path is denied. + nobodyDir := "/tmp/cubesandbox-go-sdk-nobody" + if _, err := nobodyFiles.MakeDir(ctx, nobodyDir); err != nil { + t.Fatalf("nobody Files.MakeDir returned error; template must provide the nobody user: %v", err) + } + nobodyPath := nobodyDir + "/owned.txt" + if err := nobodyFiles.Write(ctx, nobodyPath, []byte("nobody-content")); err != nil { + t.Fatalf("nobody Files.Write returned error: %v", err) + } + nobodyContent, err := nobodyFiles.Read(ctx, nobodyPath) + if err != nil { + t.Fatalf("nobody Files.Read returned error: %v", err) + } + if nobodyContent != "nobody-content" { + t.Fatalf("nobody file content=%q, want nobody-content", nobodyContent) + } + nobodyEntry, err := nobodyFiles.Stat(ctx, nobodyPath) + if err != nil { + t.Fatalf("nobody Files.Stat returned error: %v", err) + } + if nobodyEntry.Owner != "nobody" { + t.Fatalf("nobody file owner=%q, want nobody", nobodyEntry.Owner) + } + + privateDir := "/tmp/cubesandbox-go-sdk-root-private" + if _, err := rootFiles.MakeDir(ctx, privateDir); err != nil { + t.Fatalf("root Files.MakeDir returned error: %v", err) + } + chmod, err := sb.Commands().Run(ctx, "chmod 0700 "+privateDir, CommandOptions{ + Timeout: 30 * time.Second, + User: "root", + }) + if err != nil { + t.Fatalf("chmod root-only directory returned error: %v", err) + } + if chmod.ExitCode != 0 { + t.Fatalf("chmod root-only directory failed: %#v", chmod) + } + + privatePath := privateDir + "/private.txt" + if err := rootFiles.Write(ctx, privatePath, []byte("root-content")); err != nil { + t.Fatalf("root Files.Write returned error: %v", err) + } + rootContent, err := rootFiles.Read(ctx, privatePath) + if err != nil { + t.Fatalf("root Files.Read returned error: %v", err) + } + if rootContent != "root-content" { + t.Fatalf("root file content=%q, want root-content", rootContent) + } + rootEntry, err := rootFiles.Stat(ctx, privatePath) + if err != nil { + t.Fatalf("root Files.Stat returned error: %v", err) + } + if rootEntry.Owner != "root" { + t.Fatalf("root file owner=%q, want root", rootEntry.Owner) + } + + // Files.Read and Files.Stat are privileged envd management operations. The + // selected user controls path expansion and ownership, but these operations + // do not switch the envd process UID. Verify the actual POSIX permission + // boundary by running commands as the requested users instead. + rootRead, err := sb.Commands().Run(ctx, "cat "+privatePath, CommandOptions{ + Timeout: 30 * time.Second, + User: "root", + Cwd: "/tmp", + }) + if err != nil { + t.Fatalf("root command read returned error: %v", err) + } + if rootRead.ExitCode != 0 || rootRead.Stdout != "root-content" { + t.Fatalf("root command read failed: %#v", rootRead) + } + + nobodyRead, err := sb.Commands().Run(ctx, "cat "+privatePath, CommandOptions{ + Timeout: 30 * time.Second, + User: "nobody", + Cwd: "/tmp", + }) + if err != nil { + t.Fatalf("nobody command read returned transport error: %v", err) + } + if nobodyRead.ExitCode == 0 { + t.Fatalf("nobody command unexpectedly accessed a root-only file: %#v", nobodyRead) + } +} + func TestIntegrationSandboxExecutionCommandsFilesAndErrors(t *testing.T) { cfg := integrationConfig(t) client := NewClient(cfg) diff --git a/sdk/go/sdk_test.go b/sdk/go/sdk_test.go index f597c30e8..b4de5f804 100644 --- a/sdk/go/sdk_test.go +++ b/sdk/go/sdk_test.go @@ -884,12 +884,15 @@ func (s *fakeProcessStarter) startProcess(_ context.Context, payload processStar type fakeFileReader struct { path string + user string content string err error } -func (r *fakeFileReader) readFile(_ context.Context, path string) (string, error) { +func (r *fakeFileReader) readFile(_ context.Context, path string, options ...fileRequestOption) (string, error) { r.path = path + opts := resolveFileRequestOptions(options...) + r.user = opts.user return r.content, r.err }