Port envd from e2b with internalized shared packages and Connect RPC

- Copy envd source from e2b-dev/infra, internalize shared dependencies
  into envd/internal/shared/ (keys, filesystem, id, smap, utils)
- Switch from gRPC to Connect RPC for all envd services
- Update module paths to git.omukk.dev/wrenn/{sandbox,sandbox/envd}
- Add proto specs (process, filesystem) with buf-based code generation
- Implement full envd: process exec, filesystem ops, port forwarding,
  cgroup management, MMDS integration, and HTTP API
- Update main module dependencies (firecracker SDK, pgx, goose, etc.)
- Remove placeholder .gitkeep files replaced by real implementations
This commit is contained in:
2026-03-09 21:03:19 +06:00
parent bd78cc068c
commit a3898d68fb
99 changed files with 17185 additions and 24 deletions

View File

@ -0,0 +1,184 @@
package filesystem
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"connectrpc.com/connect"
"git.omukk.dev/wrenn/sandbox/envd/internal/permissions"
rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
)
func (s Service) ListDir(ctx context.Context, req *connect.Request[rpc.ListDirRequest]) (*connect.Response[rpc.ListDirResponse], error) {
depth := req.Msg.GetDepth()
if depth == 0 {
depth = 1 // default depth to current directory
}
u, err := permissions.GetAuthUser(ctx, s.defaults.User)
if err != nil {
return nil, err
}
requestedPath := req.Msg.GetPath()
// Expand the path so we can return absolute paths in the response.
requestedPath, err = permissions.ExpandAndResolve(requestedPath, u, s.defaults.Workdir)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
resolvedPath, err := followSymlink(requestedPath)
if err != nil {
return nil, err
}
err = checkIfDirectory(resolvedPath)
if err != nil {
return nil, err
}
entries, err := walkDir(requestedPath, resolvedPath, int(depth))
if err != nil {
return nil, err
}
return connect.NewResponse(&rpc.ListDirResponse{
Entries: entries,
}), nil
}
func (s Service) MakeDir(ctx context.Context, req *connect.Request[rpc.MakeDirRequest]) (*connect.Response[rpc.MakeDirResponse], error) {
u, err := permissions.GetAuthUser(ctx, s.defaults.User)
if err != nil {
return nil, err
}
dirPath, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
stat, err := os.Stat(dirPath)
if err != nil && !os.IsNotExist(err) {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error getting file info: %w", err))
}
if err == nil {
if stat.IsDir() {
return nil, connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("directory already exists: %s", dirPath))
}
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path already exists but it is not a directory: %s", dirPath))
}
uid, gid, userErr := permissions.GetUserIdInts(u)
if userErr != nil {
return nil, connect.NewError(connect.CodeInternal, userErr)
}
userErr = permissions.EnsureDirs(dirPath, uid, gid)
if userErr != nil {
return nil, connect.NewError(connect.CodeInternal, userErr)
}
entry, err := entryInfo(dirPath)
if err != nil {
return nil, err
}
return connect.NewResponse(&rpc.MakeDirResponse{
Entry: entry,
}), nil
}
// followSymlink resolves a symbolic link to its target path.
func followSymlink(path string) (string, error) {
// Resolve symlinks
resolvedPath, err := filepath.EvalSymlinks(path)
if err != nil {
if os.IsNotExist(err) {
return "", connect.NewError(connect.CodeNotFound, fmt.Errorf("path not found: %w", err))
}
if strings.Contains(err.Error(), "too many links") {
return "", connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("cyclic symlink or chain >255 links at %q", path))
}
return "", connect.NewError(connect.CodeInternal, fmt.Errorf("error resolving symlink: %w", err))
}
return resolvedPath, nil
}
// checkIfDirectory checks if the given path is a directory.
func checkIfDirectory(path string) error {
stat, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return connect.NewError(connect.CodeNotFound, fmt.Errorf("directory not found: %w", err))
}
return connect.NewError(connect.CodeInternal, fmt.Errorf("error getting file info: %w", err))
}
if !stat.IsDir() {
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path is not a directory: %s", path))
}
return nil
}
// walkDir walks the directory tree starting from dirPath up to the specified depth (doesn't follow symlinks).
func walkDir(requestedPath string, dirPath string, depth int) (entries []*rpc.EntryInfo, err error) {
err = filepath.WalkDir(dirPath, func(path string, _ os.DirEntry, err error) error {
if err != nil {
return err
}
// Skip the root directory itself
if path == dirPath {
return nil
}
// Calculate current depth
relPath, err := filepath.Rel(dirPath, path)
if err != nil {
return err
}
currentDepth := len(strings.Split(relPath, string(os.PathSeparator)))
if currentDepth > depth {
return filepath.SkipDir
}
entryInfo, err := entryInfo(path)
if err != nil {
var connectErr *connect.Error
if errors.As(err, &connectErr) && connectErr.Code() == connect.CodeNotFound {
// Skip entries that don't exist anymore
return nil
}
return err
}
// Return the requested path as the base path instead of the symlink-resolved path
path = filepath.Join(requestedPath, relPath)
entryInfo.Path = path
entries = append(entries, entryInfo)
return nil
})
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error reading directory %s: %w", dirPath, err))
}
return entries, nil
}

View File

@ -0,0 +1,405 @@
package filesystem
import (
"context"
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"testing"
"connectrpc.com/authn"
"connectrpc.com/connect"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
)
func TestListDir(t *testing.T) {
t.Parallel()
// Setup temp root and user
root := t.TempDir()
u, err := user.Current()
require.NoError(t, err)
// Setup directory structure
testFolder := filepath.Join(root, "test")
require.NoError(t, os.MkdirAll(filepath.Join(testFolder, "test-dir", "sub-dir-1"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(testFolder, "test-dir", "sub-dir-2"), 0o755))
filePath := filepath.Join(testFolder, "test-dir", "sub-dir-1", "file.txt")
require.NoError(t, os.WriteFile(filePath, []byte("Hello, World!"), 0o644))
// Service instance
svc := mockService()
// Helper to inject user into context
injectUser := func(ctx context.Context, u *user.User) context.Context {
return authn.SetInfo(ctx, u)
}
tests := []struct {
name string
depth uint32
expectedPaths []string
}{
{
name: "depth 0 lists only root directory",
depth: 0,
expectedPaths: []string{
filepath.Join(testFolder, "test-dir"),
},
},
{
name: "depth 1 lists root directory",
depth: 1,
expectedPaths: []string{
filepath.Join(testFolder, "test-dir"),
},
},
{
name: "depth 2 lists first level of subdirectories (in this case the root directory)",
depth: 2,
expectedPaths: []string{
filepath.Join(testFolder, "test-dir"),
filepath.Join(testFolder, "test-dir", "sub-dir-1"),
filepath.Join(testFolder, "test-dir", "sub-dir-2"),
},
},
{
name: "depth 3 lists all directories and files",
depth: 3,
expectedPaths: []string{
filepath.Join(testFolder, "test-dir"),
filepath.Join(testFolder, "test-dir", "sub-dir-1"),
filepath.Join(testFolder, "test-dir", "sub-dir-2"),
filepath.Join(testFolder, "test-dir", "sub-dir-1", "file.txt"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := injectUser(t.Context(), u)
req := connect.NewRequest(&filesystem.ListDirRequest{
Path: testFolder,
Depth: tt.depth,
})
resp, err := svc.ListDir(ctx, req)
require.NoError(t, err)
assert.NotEmpty(t, resp.Msg)
assert.Len(t, resp.Msg.GetEntries(), len(tt.expectedPaths))
actualPaths := make([]string, len(resp.Msg.GetEntries()))
for i, entry := range resp.Msg.GetEntries() {
actualPaths[i] = entry.GetPath()
}
assert.ElementsMatch(t, tt.expectedPaths, actualPaths)
})
}
}
func TestListDirNonExistingPath(t *testing.T) {
t.Parallel()
svc := mockService()
u, err := user.Current()
require.NoError(t, err)
ctx := authn.SetInfo(t.Context(), u)
req := connect.NewRequest(&filesystem.ListDirRequest{
Path: "/non-existing-path",
Depth: 1,
})
_, err = svc.ListDir(ctx, req)
require.Error(t, err)
var connectErr *connect.Error
ok := errors.As(err, &connectErr)
assert.True(t, ok, "expected error to be of type *connect.Error")
assert.Equal(t, connect.CodeNotFound, connectErr.Code())
}
func TestListDirRelativePath(t *testing.T) {
t.Parallel()
// Setup temp root and user
u, err := user.Current()
require.NoError(t, err)
// Setup directory structure
testRelativePath := fmt.Sprintf("test-%s", uuid.New())
testFolderPath := filepath.Join(u.HomeDir, testRelativePath)
filePath := filepath.Join(testFolderPath, "file.txt")
require.NoError(t, os.MkdirAll(testFolderPath, 0o755))
require.NoError(t, os.WriteFile(filePath, []byte("Hello, World!"), 0o644))
// Service instance
svc := mockService()
ctx := authn.SetInfo(t.Context(), u)
req := connect.NewRequest(&filesystem.ListDirRequest{
Path: testRelativePath,
Depth: 1,
})
resp, err := svc.ListDir(ctx, req)
require.NoError(t, err)
assert.NotEmpty(t, resp.Msg)
expectedPaths := []string{
filepath.Join(testFolderPath, "file.txt"),
}
assert.Len(t, resp.Msg.GetEntries(), len(expectedPaths))
actualPaths := make([]string, len(resp.Msg.GetEntries()))
for i, entry := range resp.Msg.GetEntries() {
actualPaths[i] = entry.GetPath()
}
assert.ElementsMatch(t, expectedPaths, actualPaths)
}
func TestListDir_Symlinks(t *testing.T) {
t.Parallel()
root := t.TempDir()
u, err := user.Current()
require.NoError(t, err)
ctx := authn.SetInfo(t.Context(), u)
symlinkRoot := filepath.Join(root, "test-symlinks")
require.NoError(t, os.MkdirAll(symlinkRoot, 0o755))
// 1. Prepare a real directory + file that a symlink will point to
realDir := filepath.Join(symlinkRoot, "real-dir")
require.NoError(t, os.MkdirAll(realDir, 0o755))
filePath := filepath.Join(realDir, "file.txt")
require.NoError(t, os.WriteFile(filePath, []byte("hello via symlink"), 0o644))
// 2. Prepare a standalone real file (points-to-file scenario)
realFile := filepath.Join(symlinkRoot, "real-file.txt")
require.NoError(t, os.WriteFile(realFile, []byte("i am a plain file"), 0o644))
// 3. Create the three symlinks
linkToDir := filepath.Join(symlinkRoot, "link-dir") // → directory
linkToFile := filepath.Join(symlinkRoot, "link-file") // → file
cyclicLink := filepath.Join(symlinkRoot, "cyclic") // → itself
require.NoError(t, os.Symlink(realDir, linkToDir))
require.NoError(t, os.Symlink(realFile, linkToFile))
require.NoError(t, os.Symlink(cyclicLink, cyclicLink))
svc := mockService()
t.Run("symlink to directory behaves like directory and the content looks like inside the directory", func(t *testing.T) {
t.Parallel()
req := connect.NewRequest(&filesystem.ListDirRequest{
Path: linkToDir,
Depth: 1,
})
resp, err := svc.ListDir(ctx, req)
require.NoError(t, err)
expected := []string{
filepath.Join(linkToDir, "file.txt"),
}
actual := make([]string, len(resp.Msg.GetEntries()))
for i, e := range resp.Msg.GetEntries() {
actual[i] = e.GetPath()
}
assert.ElementsMatch(t, expected, actual)
})
t.Run("link to file", func(t *testing.T) {
t.Parallel()
req := connect.NewRequest(&filesystem.ListDirRequest{
Path: linkToFile,
Depth: 1,
})
_, err := svc.ListDir(ctx, req)
require.Error(t, err)
assert.Contains(t, err.Error(), "not a directory")
})
t.Run("cyclic symlink surfaces 'too many links' → invalid-argument", func(t *testing.T) {
t.Parallel()
req := connect.NewRequest(&filesystem.ListDirRequest{
Path: cyclicLink,
})
_, err := svc.ListDir(ctx, req)
require.Error(t, err)
var connectErr *connect.Error
ok := errors.As(err, &connectErr)
assert.True(t, ok, "expected error to be of type *connect.Error")
assert.Equal(t, connect.CodeFailedPrecondition, connectErr.Code())
assert.Contains(t, connectErr.Error(), "cyclic symlink")
})
t.Run("symlink not resolved if not root", func(t *testing.T) {
t.Parallel()
req := connect.NewRequest(&filesystem.ListDirRequest{
Path: symlinkRoot,
Depth: 3,
})
res, err := svc.ListDir(ctx, req)
require.NoError(t, err)
expected := []string{
filepath.Join(symlinkRoot, "cyclic"),
filepath.Join(symlinkRoot, "link-dir"),
filepath.Join(symlinkRoot, "link-file"),
filepath.Join(symlinkRoot, "real-dir"),
filepath.Join(symlinkRoot, "real-dir", "file.txt"),
filepath.Join(symlinkRoot, "real-file.txt"),
}
actual := make([]string, len(res.Msg.GetEntries()))
for i, e := range res.Msg.GetEntries() {
actual[i] = e.GetPath()
}
assert.ElementsMatch(t, expected, actual, "symlinks should not be resolved when listing the symlink root directory")
})
}
// TestFollowSymlink_Success makes sure that followSymlink resolves symlinks,
// while also being robust to the /var → /private/var indirection that exists on macOS.
func TestFollowSymlink_Success(t *testing.T) {
t.Parallel()
// Base temporary directory. On macOS this lives under /var/folders/…
// which itself is a symlink to /private/var/folders/….
base := t.TempDir()
// Create a real directory that we ultimately want to resolve to.
target := filepath.Join(base, "target")
require.NoError(t, os.MkdirAll(target, 0o755))
// Create a symlink pointing at the real directory so we can verify that
// followSymlink follows it.
link := filepath.Join(base, "link")
require.NoError(t, os.Symlink(target, link))
got, err := followSymlink(link)
require.NoError(t, err)
// Canonicalise the expected path too, so that /var → /private/var (macOS)
// or any other benign symlink indirections dont cause flaky tests.
want, err := filepath.EvalSymlinks(link)
require.NoError(t, err)
require.Equal(t, want, got, "followSymlink should resolve and canonicalise symlinks")
}
// TestFollowSymlink_MultiSymlinkChain verifies that followSymlink follows a chain
// of several symlinks (noncyclic) correctly.
func TestFollowSymlink_MultiSymlinkChain(t *testing.T) {
t.Parallel()
base := t.TempDir()
// Final destination directory.
target := filepath.Join(base, "target")
require.NoError(t, os.MkdirAll(target, 0o755))
// Build a 3link chain: link1 → link2 → link3 → target.
link3 := filepath.Join(base, "link3")
require.NoError(t, os.Symlink(target, link3))
link2 := filepath.Join(base, "link2")
require.NoError(t, os.Symlink(link3, link2))
link1 := filepath.Join(base, "link1")
require.NoError(t, os.Symlink(link2, link1))
got, err := followSymlink(link1)
require.NoError(t, err)
want, err := filepath.EvalSymlinks(link1)
require.NoError(t, err)
require.Equal(t, want, got, "followSymlink should resolve an arbitrary symlink chain")
}
func TestFollowSymlink_NotFound(t *testing.T) {
t.Parallel()
_, err := followSymlink("/definitely/does/not/exist")
require.Error(t, err)
var cerr *connect.Error
require.ErrorAs(t, err, &cerr)
require.Equal(t, connect.CodeNotFound, cerr.Code())
}
func TestFollowSymlink_CyclicSymlink(t *testing.T) {
t.Parallel()
dir := t.TempDir()
a := filepath.Join(dir, "a")
b := filepath.Join(dir, "b")
require.NoError(t, os.MkdirAll(a, 0o755))
require.NoError(t, os.MkdirAll(b, 0o755))
// Create a twonode loop: a/loop → b/loop, b/loop → a/loop.
require.NoError(t, os.Symlink(filepath.Join(b, "loop"), filepath.Join(a, "loop")))
require.NoError(t, os.Symlink(filepath.Join(a, "loop"), filepath.Join(b, "loop")))
_, err := followSymlink(filepath.Join(a, "loop"))
require.Error(t, err)
var cerr *connect.Error
require.ErrorAs(t, err, &cerr)
require.Equal(t, connect.CodeFailedPrecondition, cerr.Code())
require.Contains(t, cerr.Message(), "cyclic")
}
func TestCheckIfDirectory(t *testing.T) {
t.Parallel()
dir := t.TempDir()
require.NoError(t, checkIfDirectory(dir))
file := filepath.Join(dir, "file.txt")
require.NoError(t, os.WriteFile(file, []byte("hello"), 0o644))
err := checkIfDirectory(file)
require.Error(t, err)
var cerr *connect.Error
require.ErrorAs(t, err, &cerr)
require.Equal(t, connect.CodeInvalidArgument, cerr.Code())
}
func TestWalkDir_Depth(t *testing.T) {
t.Parallel()
root := t.TempDir()
sub := filepath.Join(root, "sub")
subsub := filepath.Join(sub, "subsub")
require.NoError(t, os.MkdirAll(subsub, 0o755))
entries, err := walkDir(root, root, 1)
require.NoError(t, err)
// Collect the names for easier assertions.
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.GetName())
}
require.Contains(t, names, "sub")
require.NotContains(t, names, "subsub", "entries beyond depth should be excluded")
}
func TestWalkDir_Error(t *testing.T) {
t.Parallel()
_, err := walkDir("/does/not/exist", "/does/not/exist", 1)
require.Error(t, err)
var cerr *connect.Error
require.ErrorAs(t, err, &cerr)
require.Equal(t, connect.CodeInternal, cerr.Code())
}

View File

@ -0,0 +1,58 @@
package filesystem
import (
"context"
"fmt"
"os"
"path/filepath"
"connectrpc.com/connect"
"git.omukk.dev/wrenn/sandbox/envd/internal/permissions"
rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
)
func (s Service) Move(ctx context.Context, req *connect.Request[rpc.MoveRequest]) (*connect.Response[rpc.MoveResponse], error) {
u, err := permissions.GetAuthUser(ctx, s.defaults.User)
if err != nil {
return nil, err
}
source, err := permissions.ExpandAndResolve(req.Msg.GetSource(), u, s.defaults.Workdir)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
destination, err := permissions.ExpandAndResolve(req.Msg.GetDestination(), u, s.defaults.Workdir)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
uid, gid, userErr := permissions.GetUserIdInts(u)
if userErr != nil {
return nil, connect.NewError(connect.CodeInternal, userErr)
}
userErr = permissions.EnsureDirs(filepath.Dir(destination), uid, gid)
if userErr != nil {
return nil, connect.NewError(connect.CodeInternal, userErr)
}
err = os.Rename(source, destination)
if err != nil {
if os.IsNotExist(err) {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("source file not found: %w", err))
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error renaming: %w", err))
}
entry, err := entryInfo(destination)
if err != nil {
return nil, err
}
return connect.NewResponse(&rpc.MoveResponse{
Entry: entry,
}), nil
}

View File

@ -0,0 +1,364 @@
package filesystem
import (
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"testing"
"connectrpc.com/authn"
"connectrpc.com/connect"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
)
func TestMove(t *testing.T) {
t.Parallel()
// Setup temp root and user
root := t.TempDir()
u, err := user.Current()
require.NoError(t, err)
// Setup source and destination directories
sourceDir := filepath.Join(root, "source")
destDir := filepath.Join(root, "destination")
require.NoError(t, os.MkdirAll(sourceDir, 0o755))
require.NoError(t, os.MkdirAll(destDir, 0o755))
// Create a test file to move
sourceFile := filepath.Join(sourceDir, "test-file.txt")
testContent := []byte("Hello, World!")
require.NoError(t, os.WriteFile(sourceFile, testContent, 0o644))
// Destination file path
destFile := filepath.Join(destDir, "test-file.txt")
// Service instance
svc := mockService()
// Call the Move function
ctx := authn.SetInfo(t.Context(), u)
req := connect.NewRequest(&filesystem.MoveRequest{
Source: sourceFile,
Destination: destFile,
})
resp, err := svc.Move(ctx, req)
// Verify the move was successful
require.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, destFile, resp.Msg.GetEntry().GetPath())
// Verify the file exists at the destination
_, err = os.Stat(destFile)
require.NoError(t, err)
// Verify the file no longer exists at the source
_, err = os.Stat(sourceFile)
assert.True(t, os.IsNotExist(err))
// Verify the content of the moved file
content, err := os.ReadFile(destFile)
require.NoError(t, err)
assert.Equal(t, testContent, content)
}
func TestMoveDirectory(t *testing.T) {
t.Parallel()
// Setup temp root and user
root := t.TempDir()
u, err := user.Current()
require.NoError(t, err)
// Setup source and destination directories
sourceParent := filepath.Join(root, "source-parent")
destParent := filepath.Join(root, "dest-parent")
require.NoError(t, os.MkdirAll(sourceParent, 0o755))
require.NoError(t, os.MkdirAll(destParent, 0o755))
// Create a test directory with files to move
sourceDir := filepath.Join(sourceParent, "test-dir")
require.NoError(t, os.MkdirAll(filepath.Join(sourceDir, "subdir"), 0o755))
// Create some files in the directory
file1 := filepath.Join(sourceDir, "file1.txt")
file2 := filepath.Join(sourceDir, "subdir", "file2.txt")
require.NoError(t, os.WriteFile(file1, []byte("File 1 content"), 0o644))
require.NoError(t, os.WriteFile(file2, []byte("File 2 content"), 0o644))
// Destination directory path
destDir := filepath.Join(destParent, "test-dir")
// Service instance
svc := mockService()
// Call the Move function
ctx := authn.SetInfo(t.Context(), u)
req := connect.NewRequest(&filesystem.MoveRequest{
Source: sourceDir,
Destination: destDir,
})
resp, err := svc.Move(ctx, req)
// Verify the move was successful
require.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, destDir, resp.Msg.GetEntry().GetPath())
// Verify the directory exists at the destination
_, err = os.Stat(destDir)
require.NoError(t, err)
// Verify the files exist at the destination
destFile1 := filepath.Join(destDir, "file1.txt")
destFile2 := filepath.Join(destDir, "subdir", "file2.txt")
_, err = os.Stat(destFile1)
require.NoError(t, err)
_, err = os.Stat(destFile2)
require.NoError(t, err)
// Verify the directory no longer exists at the source
_, err = os.Stat(sourceDir)
assert.True(t, os.IsNotExist(err))
// Verify the content of the moved files
content1, err := os.ReadFile(destFile1)
require.NoError(t, err)
assert.Equal(t, []byte("File 1 content"), content1)
content2, err := os.ReadFile(destFile2)
require.NoError(t, err)
assert.Equal(t, []byte("File 2 content"), content2)
}
func TestMoveNonExistingFile(t *testing.T) {
t.Parallel()
// Setup temp root and user
root := t.TempDir()
u, err := user.Current()
require.NoError(t, err)
// Setup destination directory
destDir := filepath.Join(root, "destination")
require.NoError(t, os.MkdirAll(destDir, 0o755))
// Non-existing source file
sourceFile := filepath.Join(root, "non-existing-file.txt")
// Destination file path
destFile := filepath.Join(destDir, "moved-file.txt")
// Service instance
svc := mockService()
// Call the Move function
ctx := authn.SetInfo(t.Context(), u)
req := connect.NewRequest(&filesystem.MoveRequest{
Source: sourceFile,
Destination: destFile,
})
_, err = svc.Move(ctx, req)
// Verify the correct error is returned
require.Error(t, err)
var connectErr *connect.Error
ok := errors.As(err, &connectErr)
assert.True(t, ok, "expected error to be of type *connect.Error")
assert.Equal(t, connect.CodeNotFound, connectErr.Code())
assert.Contains(t, connectErr.Message(), "source file not found")
}
func TestMoveRelativePath(t *testing.T) {
t.Parallel()
// Setup user
u, err := user.Current()
require.NoError(t, err)
// Setup directory structure with unique name to avoid conflicts
testRelativePath := fmt.Sprintf("test-move-%s", uuid.New())
testFolderPath := filepath.Join(u.HomeDir, testRelativePath)
require.NoError(t, os.MkdirAll(testFolderPath, 0o755))
// Create a test file to move
sourceFile := filepath.Join(testFolderPath, "source-file.txt")
testContent := []byte("Hello from relative path!")
require.NoError(t, os.WriteFile(sourceFile, testContent, 0o644))
// Destination file path (also relative)
destRelativePath := fmt.Sprintf("test-move-dest-%s", uuid.New())
destFolderPath := filepath.Join(u.HomeDir, destRelativePath)
require.NoError(t, os.MkdirAll(destFolderPath, 0o755))
destFile := filepath.Join(destFolderPath, "moved-file.txt")
// Service instance
svc := mockService()
// Call the Move function with relative paths
ctx := authn.SetInfo(t.Context(), u)
req := connect.NewRequest(&filesystem.MoveRequest{
Source: filepath.Join(testRelativePath, "source-file.txt"), // Relative path
Destination: filepath.Join(destRelativePath, "moved-file.txt"), // Relative path
})
resp, err := svc.Move(ctx, req)
// Verify the move was successful
require.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, destFile, resp.Msg.GetEntry().GetPath())
// Verify the file exists at the destination
_, err = os.Stat(destFile)
require.NoError(t, err)
// Verify the file no longer exists at the source
_, err = os.Stat(sourceFile)
assert.True(t, os.IsNotExist(err))
// Verify the content of the moved file
content, err := os.ReadFile(destFile)
require.NoError(t, err)
assert.Equal(t, testContent, content)
// Clean up
os.RemoveAll(testFolderPath)
os.RemoveAll(destFolderPath)
}
func TestMove_Symlinks(t *testing.T) { //nolint:tparallel // this test cannot be executed in parallel
root := t.TempDir()
u, err := user.Current()
require.NoError(t, err)
ctx := authn.SetInfo(t.Context(), u)
// Setup source and destination directories
sourceRoot := filepath.Join(root, "source")
destRoot := filepath.Join(root, "destination")
require.NoError(t, os.MkdirAll(sourceRoot, 0o755))
require.NoError(t, os.MkdirAll(destRoot, 0o755))
// 1. Prepare a real directory + file that a symlink will point to
realDir := filepath.Join(sourceRoot, "real-dir")
require.NoError(t, os.MkdirAll(realDir, 0o755))
filePath := filepath.Join(realDir, "file.txt")
require.NoError(t, os.WriteFile(filePath, []byte("hello via symlink"), 0o644))
// 2. Prepare a standalone real file (points-to-file scenario)
realFile := filepath.Join(sourceRoot, "real-file.txt")
require.NoError(t, os.WriteFile(realFile, []byte("i am a plain file"), 0o644))
// 3. Create symlinks
linkToDir := filepath.Join(sourceRoot, "link-dir") // → directory
linkToFile := filepath.Join(sourceRoot, "link-file") // → file
require.NoError(t, os.Symlink(realDir, linkToDir))
require.NoError(t, os.Symlink(realFile, linkToFile))
svc := mockService()
t.Run("move symlink to directory", func(t *testing.T) {
t.Parallel()
destPath := filepath.Join(destRoot, "moved-link-dir")
req := connect.NewRequest(&filesystem.MoveRequest{
Source: linkToDir,
Destination: destPath,
})
resp, err := svc.Move(ctx, req)
require.NoError(t, err)
assert.Equal(t, destPath, resp.Msg.GetEntry().GetPath())
// Verify the symlink was moved
_, err = os.Stat(destPath)
require.NoError(t, err)
// Verify it's still a symlink
info, err := os.Lstat(destPath)
require.NoError(t, err)
assert.NotEqual(t, 0, info.Mode()&os.ModeSymlink, "expected a symlink")
// Verify the symlink target is still correct
target, err := os.Readlink(destPath)
require.NoError(t, err)
assert.Equal(t, realDir, target)
// Verify the original symlink is gone
_, err = os.Stat(linkToDir)
assert.True(t, os.IsNotExist(err))
// Verify the real directory still exists
_, err = os.Stat(realDir)
assert.NoError(t, err)
})
t.Run("move symlink to file", func(t *testing.T) { //nolint:paralleltest
destPath := filepath.Join(destRoot, "moved-link-file")
req := connect.NewRequest(&filesystem.MoveRequest{
Source: linkToFile,
Destination: destPath,
})
resp, err := svc.Move(ctx, req)
require.NoError(t, err)
assert.Equal(t, destPath, resp.Msg.GetEntry().GetPath())
// Verify the symlink was moved
_, err = os.Stat(destPath)
require.NoError(t, err)
// Verify it's still a symlink
info, err := os.Lstat(destPath)
require.NoError(t, err)
assert.NotEqual(t, 0, info.Mode()&os.ModeSymlink, "expected a symlink")
// Verify the symlink target is still correct
target, err := os.Readlink(destPath)
require.NoError(t, err)
assert.Equal(t, realFile, target)
// Verify the original symlink is gone
_, err = os.Stat(linkToFile)
assert.True(t, os.IsNotExist(err))
// Verify the real file still exists
_, err = os.Stat(realFile)
assert.NoError(t, err)
})
t.Run("move real file that is target of symlink", func(t *testing.T) {
t.Parallel()
// Create a new symlink to the real file
newLinkToFile := filepath.Join(sourceRoot, "new-link-file")
require.NoError(t, os.Symlink(realFile, newLinkToFile))
destPath := filepath.Join(destRoot, "moved-real-file.txt")
req := connect.NewRequest(&filesystem.MoveRequest{
Source: realFile,
Destination: destPath,
})
resp, err := svc.Move(ctx, req)
require.NoError(t, err)
assert.Equal(t, destPath, resp.Msg.GetEntry().GetPath())
// Verify the real file was moved
_, err = os.Stat(destPath)
require.NoError(t, err)
// Verify the original file is gone
_, err = os.Stat(realFile)
assert.True(t, os.IsNotExist(err))
// Verify the symlink still exists but now points to a non-existent file
_, err = os.Stat(newLinkToFile)
require.Error(t, err, "symlink should point to non-existent file")
})
}

View File

@ -0,0 +1,31 @@
package filesystem
import (
"context"
"fmt"
"os"
"connectrpc.com/connect"
"git.omukk.dev/wrenn/sandbox/envd/internal/permissions"
rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
)
func (s Service) Remove(ctx context.Context, req *connect.Request[rpc.RemoveRequest]) (*connect.Response[rpc.RemoveResponse], error) {
u, err := permissions.GetAuthUser(ctx, s.defaults.User)
if err != nil {
return nil, err
}
path, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
err = os.RemoveAll(path)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error removing file or directory: %w", err))
}
return connect.NewResponse(&rpc.RemoveResponse{}), nil
}

View File

@ -0,0 +1,34 @@
package filesystem
import (
"connectrpc.com/connect"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog"
"git.omukk.dev/wrenn/sandbox/envd/internal/execcontext"
"git.omukk.dev/wrenn/sandbox/envd/internal/logs"
spec "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem/filesystemconnect"
"git.omukk.dev/wrenn/sandbox/envd/internal/utils"
)
type Service struct {
logger *zerolog.Logger
watchers *utils.Map[string, *FileWatcher]
defaults *execcontext.Defaults
}
func Handle(server *chi.Mux, l *zerolog.Logger, defaults *execcontext.Defaults) {
service := Service{
logger: l,
watchers: utils.NewMap[string, *FileWatcher](),
defaults: defaults,
}
interceptors := connect.WithInterceptors(
logs.NewUnaryLogInterceptor(l),
)
path, handler := spec.NewFilesystemHandler(service, interceptors)
server.Mount(path, handler)
}

View File

@ -0,0 +1,14 @@
package filesystem
import (
"git.omukk.dev/wrenn/sandbox/envd/internal/execcontext"
"git.omukk.dev/wrenn/sandbox/envd/internal/utils"
)
func mockService() Service {
return Service{
defaults: &execcontext.Defaults{
EnvVars: utils.NewMap[string, string](),
},
}
}

View File

@ -0,0 +1,29 @@
package filesystem
import (
"context"
"connectrpc.com/connect"
"git.omukk.dev/wrenn/sandbox/envd/internal/permissions"
rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
)
func (s Service) Stat(ctx context.Context, req *connect.Request[rpc.StatRequest]) (*connect.Response[rpc.StatResponse], error) {
u, err := permissions.GetAuthUser(ctx, s.defaults.User)
if err != nil {
return nil, err
}
path, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
entry, err := entryInfo(path)
if err != nil {
return nil, err
}
return connect.NewResponse(&rpc.StatResponse{Entry: entry}), nil
}

View File

@ -0,0 +1,114 @@
package filesystem
import (
"context"
"os"
"os/user"
"path/filepath"
"testing"
"connectrpc.com/authn"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
)
func TestStat(t *testing.T) {
t.Parallel()
// Setup temp root and user
root := t.TempDir()
// Get the actual path to the temp directory (symlinks can cause issues)
root, err := filepath.EvalSymlinks(root)
require.NoError(t, err)
u, err := user.Current()
require.NoError(t, err)
group, err := user.LookupGroupId(u.Gid)
require.NoError(t, err)
// Setup directory structure
testFolder := filepath.Join(root, "test")
err = os.MkdirAll(testFolder, 0o755)
require.NoError(t, err)
testFile := filepath.Join(testFolder, "file.txt")
err = os.WriteFile(testFile, []byte("Hello, World!"), 0o644)
require.NoError(t, err)
linkedFile := filepath.Join(testFolder, "linked-file.txt")
err = os.Symlink(testFile, linkedFile)
require.NoError(t, err)
// Service instance
svc := mockService()
// Helper to inject user into context
injectUser := func(ctx context.Context, u *user.User) context.Context {
return authn.SetInfo(ctx, u)
}
tests := []struct {
name string
path string
}{
{
name: "Stat file directory",
path: testFile,
},
{
name: "Stat symlink to file",
path: linkedFile,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := injectUser(t.Context(), u)
req := connect.NewRequest(&filesystem.StatRequest{
Path: tt.path,
})
resp, err := svc.Stat(ctx, req)
require.NoError(t, err)
require.NotEmpty(t, resp.Msg)
require.NotNil(t, resp.Msg.GetEntry())
assert.Equal(t, tt.path, resp.Msg.GetEntry().GetPath())
assert.Equal(t, filesystem.FileType_FILE_TYPE_FILE, resp.Msg.GetEntry().GetType())
assert.Equal(t, u.Username, resp.Msg.GetEntry().GetOwner())
assert.Equal(t, group.Name, resp.Msg.GetEntry().GetGroup())
assert.Equal(t, uint32(0o644), resp.Msg.GetEntry().GetMode())
if tt.path == linkedFile {
require.NotNil(t, resp.Msg.GetEntry().GetSymlinkTarget())
assert.Equal(t, testFile, resp.Msg.GetEntry().GetSymlinkTarget())
} else {
assert.Empty(t, resp.Msg.GetEntry().GetSymlinkTarget())
}
})
}
}
func TestStatMissingPathReturnsNotFound(t *testing.T) {
t.Parallel()
u, err := user.Current()
require.NoError(t, err)
svc := mockService()
ctx := authn.SetInfo(t.Context(), u)
req := connect.NewRequest(&filesystem.StatRequest{
Path: filepath.Join(t.TempDir(), "missing.txt"),
})
_, err = svc.Stat(ctx, req)
require.Error(t, err)
var connectErr *connect.Error
require.ErrorAs(t, err, &connectErr)
assert.Equal(t, connect.CodeNotFound, connectErr.Code())
}

View File

@ -0,0 +1,107 @@
package filesystem
import (
"fmt"
"os"
"os/user"
"syscall"
"time"
"connectrpc.com/connect"
"google.golang.org/protobuf/types/known/timestamppb"
rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
"git.omukk.dev/wrenn/sandbox/envd/internal/shared/filesystem"
)
// Filesystem magic numbers from Linux kernel (include/uapi/linux/magic.h)
const (
nfsSuperMagic = 0x6969
cifsMagic = 0xFF534D42
smbSuperMagic = 0x517B
smb2MagicNumber = 0xFE534D42
fuseSuperMagic = 0x65735546
)
// IsPathOnNetworkMount checks if the given path is on a network filesystem mount.
// Returns true if the path is on NFS, CIFS, SMB, or FUSE filesystem.
func IsPathOnNetworkMount(path string) (bool, error) {
var statfs syscall.Statfs_t
if err := syscall.Statfs(path, &statfs); err != nil {
return false, fmt.Errorf("failed to statfs %s: %w", path, err)
}
switch statfs.Type {
case nfsSuperMagic, cifsMagic, smbSuperMagic, smb2MagicNumber, fuseSuperMagic:
return true, nil
default:
return false, nil
}
}
func entryInfo(path string) (*rpc.EntryInfo, error) {
info, err := filesystem.GetEntryFromPath(path)
if err != nil {
if os.IsNotExist(err) {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("file not found: %w", err))
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error getting file info: %w", err))
}
owner, group := getFileOwnership(info)
return &rpc.EntryInfo{
Name: info.Name,
Type: getEntryType(info.Type),
Path: info.Path,
Size: info.Size,
Mode: uint32(info.Mode),
Permissions: info.Permissions,
Owner: owner,
Group: group,
ModifiedTime: toTimestamp(info.ModifiedTime),
SymlinkTarget: info.SymlinkTarget,
}, nil
}
func toTimestamp(time time.Time) *timestamppb.Timestamp {
if time.IsZero() {
return nil
}
return timestamppb.New(time)
}
// getFileOwnership returns the owner and group names for a file.
// If the lookup fails, it returns the numeric UID and GID as strings.
func getFileOwnership(fileInfo filesystem.EntryInfo) (owner, group string) {
// Look up username
owner = fmt.Sprintf("%d", fileInfo.UID)
if u, err := user.LookupId(owner); err == nil {
owner = u.Username
}
// Look up group name
group = fmt.Sprintf("%d", fileInfo.GID)
if g, err := user.LookupGroupId(group); err == nil {
group = g.Name
}
return owner, group
}
// getEntryType determines the type of file entry based on its mode and path.
// If the file is a symlink, it follows the symlink to determine the actual type.
func getEntryType(fileType filesystem.FileType) rpc.FileType {
switch fileType {
case filesystem.FileFileType:
return rpc.FileType_FILE_TYPE_FILE
case filesystem.DirectoryFileType:
return rpc.FileType_FILE_TYPE_DIRECTORY
case filesystem.SymlinkFileType:
return rpc.FileType_FILE_TYPE_SYMLINK
default:
return rpc.FileType_FILE_TYPE_UNSPECIFIED
}
}

View File

@ -0,0 +1,149 @@
package filesystem
import (
"context"
"os/exec"
osuser "os/user"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
fsmodel "git.omukk.dev/wrenn/sandbox/envd/internal/shared/filesystem"
)
func TestIsPathOnNetworkMount(t *testing.T) {
t.Parallel()
// Test with a regular directory (should not be on network mount)
tempDir := t.TempDir()
isNetwork, err := IsPathOnNetworkMount(tempDir)
require.NoError(t, err)
assert.False(t, isNetwork, "temp directory should not be on a network mount")
}
func TestIsPathOnNetworkMount_FuseMount(t *testing.T) {
t.Parallel()
// Require bindfs to be available
_, err := exec.LookPath("bindfs")
require.NoError(t, err, "bindfs must be installed for this test")
// Require fusermount to be available (needed for unmounting)
_, err = exec.LookPath("fusermount")
require.NoError(t, err, "fusermount must be installed for this test")
// Create source and mount directories
sourceDir := t.TempDir()
mountDir := t.TempDir()
// Mount sourceDir onto mountDir using bindfs (FUSE)
ctx := context.Background()
cmd := exec.CommandContext(ctx, "bindfs", sourceDir, mountDir)
require.NoError(t, cmd.Run(), "failed to mount bindfs")
// Ensure we unmount on cleanup
t.Cleanup(func() {
_ = exec.CommandContext(context.Background(), "fusermount", "-u", mountDir).Run()
})
// Test that the FUSE mount is detected
isNetwork, err := IsPathOnNetworkMount(mountDir)
require.NoError(t, err)
assert.True(t, isNetwork, "FUSE mount should be detected as network filesystem")
// Test that the source directory is NOT detected as network mount
isNetworkSource, err := IsPathOnNetworkMount(sourceDir)
require.NoError(t, err)
assert.False(t, isNetworkSource, "source directory should not be detected as network filesystem")
}
func TestGetFileOwnership_CurrentUser(t *testing.T) {
t.Parallel()
t.Run("current user", func(t *testing.T) {
t.Parallel()
// Get current user running the tests
cur, err := osuser.Current()
if err != nil {
t.Skipf("unable to determine current user: %v", err)
}
// Determine expected owner/group using the same lookup logic
expectedOwner := cur.Uid
if u, err := osuser.LookupId(cur.Uid); err == nil {
expectedOwner = u.Username
}
expectedGroup := cur.Gid
if g, err := osuser.LookupGroupId(cur.Gid); err == nil {
expectedGroup = g.Name
}
// Parse UID/GID strings to uint32 for EntryInfo
uid64, err := strconv.ParseUint(cur.Uid, 10, 32)
require.NoError(t, err)
gid64, err := strconv.ParseUint(cur.Gid, 10, 32)
require.NoError(t, err)
// Build a minimal EntryInfo with current UID/GID
info := fsmodel.EntryInfo{ // from shared pkg
UID: uint32(uid64),
GID: uint32(gid64),
}
owner, group := getFileOwnership(info)
assert.Equal(t, expectedOwner, owner)
assert.Equal(t, expectedGroup, group)
})
t.Run("no user", func(t *testing.T) {
t.Parallel()
// Find a UID that does not exist on this system
var unknownUIDStr string
for i := 60001; i < 70000; i++ { // search a high range typically unused
idStr := strconv.Itoa(i)
if _, err := osuser.LookupId(idStr); err != nil {
unknownUIDStr = idStr
break
}
}
if unknownUIDStr == "" {
t.Skip("could not find a non-existent UID in the probed range")
}
// Find a GID that does not exist on this system
var unknownGIDStr string
for i := 60001; i < 70000; i++ { // search a high range typically unused
idStr := strconv.Itoa(i)
if _, err := osuser.LookupGroupId(idStr); err != nil {
unknownGIDStr = idStr
break
}
}
if unknownGIDStr == "" {
t.Skip("could not find a non-existent GID in the probed range")
}
// Parse to uint32 for EntryInfo construction
uid64, err := strconv.ParseUint(unknownUIDStr, 10, 32)
require.NoError(t, err)
gid64, err := strconv.ParseUint(unknownGIDStr, 10, 32)
require.NoError(t, err)
info := fsmodel.EntryInfo{
UID: uint32(uid64),
GID: uint32(gid64),
}
owner, group := getFileOwnership(info)
// Expect numeric fallbacks because lookups should fail for unknown IDs
assert.Equal(t, unknownUIDStr, owner)
assert.Equal(t, unknownGIDStr, group)
})
}

View File

@ -0,0 +1,159 @@
package filesystem
import (
"context"
"fmt"
"os"
"path/filepath"
"connectrpc.com/connect"
"github.com/e2b-dev/fsnotify"
"git.omukk.dev/wrenn/sandbox/envd/internal/logs"
"git.omukk.dev/wrenn/sandbox/envd/internal/permissions"
rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
"git.omukk.dev/wrenn/sandbox/envd/internal/utils"
)
func (s Service) WatchDir(ctx context.Context, req *connect.Request[rpc.WatchDirRequest], stream *connect.ServerStream[rpc.WatchDirResponse]) error {
return logs.LogServerStreamWithoutEvents(ctx, s.logger, req, stream, s.watchHandler)
}
func (s Service) watchHandler(ctx context.Context, req *connect.Request[rpc.WatchDirRequest], stream *connect.ServerStream[rpc.WatchDirResponse]) error {
u, err := permissions.GetAuthUser(ctx, s.defaults.User)
if err != nil {
return err
}
watchPath, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir)
if err != nil {
return connect.NewError(connect.CodeInvalidArgument, err)
}
info, err := os.Stat(watchPath)
if err != nil {
if os.IsNotExist(err) {
return connect.NewError(connect.CodeNotFound, fmt.Errorf("path %s not found: %w", watchPath, err))
}
return connect.NewError(connect.CodeInternal, fmt.Errorf("error statting path %s: %w", watchPath, err))
}
if !info.IsDir() {
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path %s not a directory: %w", watchPath, err))
}
// Check if path is on a network filesystem mount
isNetworkMount, err := IsPathOnNetworkMount(watchPath)
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("error checking mount status: %w", err))
}
if isNetworkMount {
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cannot watch path on network filesystem: %s", watchPath))
}
w, err := fsnotify.NewWatcher()
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("error creating watcher: %w", err))
}
defer w.Close()
err = w.Add(utils.FsnotifyPath(watchPath, req.Msg.GetRecursive()))
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("error adding path %s to watcher: %w", watchPath, err))
}
err = stream.Send(&rpc.WatchDirResponse{
Event: &rpc.WatchDirResponse_Start{
Start: &rpc.WatchDirResponse_StartEvent{},
},
})
if err != nil {
return connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending start event: %w", err))
}
keepaliveTicker, resetKeepalive := permissions.GetKeepAliveTicker(req)
defer keepaliveTicker.Stop()
for {
select {
case <-keepaliveTicker.C:
streamErr := stream.Send(&rpc.WatchDirResponse{
Event: &rpc.WatchDirResponse_Keepalive{
Keepalive: &rpc.WatchDirResponse_KeepAlive{},
},
})
if streamErr != nil {
return connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending keepalive: %w", streamErr))
}
case <-ctx.Done():
return ctx.Err()
case chErr, ok := <-w.Errors:
if !ok {
return connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error channel closed"))
}
return connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error: %w", chErr))
case e, ok := <-w.Events:
if !ok {
return connect.NewError(connect.CodeInternal, fmt.Errorf("watcher event channel closed"))
}
// One event can have multiple operations.
ops := []rpc.EventType{}
if fsnotify.Create.Has(e.Op) {
ops = append(ops, rpc.EventType_EVENT_TYPE_CREATE)
}
if fsnotify.Rename.Has(e.Op) {
ops = append(ops, rpc.EventType_EVENT_TYPE_RENAME)
}
if fsnotify.Chmod.Has(e.Op) {
ops = append(ops, rpc.EventType_EVENT_TYPE_CHMOD)
}
if fsnotify.Write.Has(e.Op) {
ops = append(ops, rpc.EventType_EVENT_TYPE_WRITE)
}
if fsnotify.Remove.Has(e.Op) {
ops = append(ops, rpc.EventType_EVENT_TYPE_REMOVE)
}
for _, op := range ops {
name, nameErr := filepath.Rel(watchPath, e.Name)
if nameErr != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("error getting relative path: %w", nameErr))
}
filesystemEvent := &rpc.WatchDirResponse_Filesystem{
Filesystem: &rpc.FilesystemEvent{
Name: name,
Type: op,
},
}
event := &rpc.WatchDirResponse{
Event: filesystemEvent,
}
streamErr := stream.Send(event)
s.logger.
Debug().
Str("event_type", "filesystem_event").
Str(string(logs.OperationIDKey), ctx.Value(logs.OperationIDKey).(string)).
Interface("filesystem_event", event).
Msg("Streaming filesystem event")
if streamErr != nil {
return connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending filesystem event: %w", streamErr))
}
resetKeepalive()
}
}
}
}

View File

@ -0,0 +1,224 @@
package filesystem
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"connectrpc.com/connect"
"github.com/e2b-dev/fsnotify"
"github.com/rs/zerolog"
"git.omukk.dev/wrenn/sandbox/envd/internal/logs"
"git.omukk.dev/wrenn/sandbox/envd/internal/permissions"
rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem"
"git.omukk.dev/wrenn/sandbox/envd/internal/utils"
"git.omukk.dev/wrenn/sandbox/envd/internal/shared/id"
)
type FileWatcher struct {
watcher *fsnotify.Watcher
Events []*rpc.FilesystemEvent
cancel func()
Error error
Lock sync.Mutex
}
func CreateFileWatcher(ctx context.Context, watchPath string, recursive bool, operationID string, logger *zerolog.Logger) (*FileWatcher, error) {
w, err := fsnotify.NewWatcher()
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating watcher: %w", err))
}
// We don't want to cancel the context when the request is finished
ctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
err = w.Add(utils.FsnotifyPath(watchPath, recursive))
if err != nil {
_ = w.Close()
cancel()
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error adding path %s to watcher: %w", watchPath, err))
}
fw := &FileWatcher{
watcher: w,
cancel: cancel,
Events: []*rpc.FilesystemEvent{},
Error: nil,
}
go func() {
for {
select {
case <-ctx.Done():
return
case chErr, ok := <-w.Errors:
if !ok {
fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error channel closed"))
return
}
fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error: %w", chErr))
return
case e, ok := <-w.Events:
if !ok {
fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("watcher event channel closed"))
return
}
// One event can have multiple operations.
ops := []rpc.EventType{}
if fsnotify.Create.Has(e.Op) {
ops = append(ops, rpc.EventType_EVENT_TYPE_CREATE)
}
if fsnotify.Rename.Has(e.Op) {
ops = append(ops, rpc.EventType_EVENT_TYPE_RENAME)
}
if fsnotify.Chmod.Has(e.Op) {
ops = append(ops, rpc.EventType_EVENT_TYPE_CHMOD)
}
if fsnotify.Write.Has(e.Op) {
ops = append(ops, rpc.EventType_EVENT_TYPE_WRITE)
}
if fsnotify.Remove.Has(e.Op) {
ops = append(ops, rpc.EventType_EVENT_TYPE_REMOVE)
}
for _, op := range ops {
name, nameErr := filepath.Rel(watchPath, e.Name)
if nameErr != nil {
fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("error getting relative path: %w", nameErr))
return
}
fw.Lock.Lock()
fw.Events = append(fw.Events, &rpc.FilesystemEvent{
Name: name,
Type: op,
})
fw.Lock.Unlock()
// these are only used for logging
filesystemEvent := &rpc.WatchDirResponse_Filesystem{
Filesystem: &rpc.FilesystemEvent{
Name: name,
Type: op,
},
}
event := &rpc.WatchDirResponse{
Event: filesystemEvent,
}
logger.
Debug().
Str("event_type", "filesystem_event").
Str(string(logs.OperationIDKey), operationID).
Interface("filesystem_event", event).
Msg("Streaming filesystem event")
}
}
}
}()
return fw, nil
}
func (fw *FileWatcher) Close() {
_ = fw.watcher.Close()
fw.cancel()
}
func (s Service) CreateWatcher(ctx context.Context, req *connect.Request[rpc.CreateWatcherRequest]) (*connect.Response[rpc.CreateWatcherResponse], error) {
u, err := permissions.GetAuthUser(ctx, s.defaults.User)
if err != nil {
return nil, err
}
watchPath, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
info, err := os.Stat(watchPath)
if err != nil {
if os.IsNotExist(err) {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("path %s not found: %w", watchPath, err))
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error statting path %s: %w", watchPath, err))
}
if !info.IsDir() {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path %s not a directory: %w", watchPath, err))
}
// Check if path is on a network filesystem mount
isNetworkMount, err := IsPathOnNetworkMount(watchPath)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error checking mount status: %w", err))
}
if isNetworkMount {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cannot watch path on network filesystem: %s", watchPath))
}
watcherId := "w" + id.Generate()
w, err := CreateFileWatcher(ctx, watchPath, req.Msg.GetRecursive(), watcherId, s.logger)
if err != nil {
return nil, err
}
s.watchers.Store(watcherId, w)
return connect.NewResponse(&rpc.CreateWatcherResponse{
WatcherId: watcherId,
}), nil
}
func (s Service) GetWatcherEvents(_ context.Context, req *connect.Request[rpc.GetWatcherEventsRequest]) (*connect.Response[rpc.GetWatcherEventsResponse], error) {
watcherId := req.Msg.GetWatcherId()
w, ok := s.watchers.Load(watcherId)
if !ok {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("watcher with id %s not found", watcherId))
}
if w.Error != nil {
return nil, w.Error
}
w.Lock.Lock()
defer w.Lock.Unlock()
events := w.Events
w.Events = []*rpc.FilesystemEvent{}
return connect.NewResponse(&rpc.GetWatcherEventsResponse{
Events: events,
}), nil
}
func (s Service) RemoveWatcher(_ context.Context, req *connect.Request[rpc.RemoveWatcherRequest]) (*connect.Response[rpc.RemoveWatcherResponse], error) {
watcherId := req.Msg.GetWatcherId()
w, ok := s.watchers.Load(watcherId)
if !ok {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("watcher with id %s not found", watcherId))
}
w.Close()
s.watchers.Delete(watcherId)
return connect.NewResponse(&rpc.RemoveWatcherResponse{}), nil
}