//go:build linux // +build linux package platform import ( "fmt" "os" "path/filepath" "testing" "github.com/safedep/dry/utils" "github.com/safedep/pmg/sandbox" "github.com/safedep/pmg/sandbox/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestBubblewrapTranslatorBasicTranslation(t *testing.T) { policy := &sandbox.SandboxPolicy{ Name: "test", Description: "test policy", PackageManagers: []string{"npm"}, Filesystem: sandbox.FilesystemPolicy{ AllowRead: []string{"/tmp"}, AllowWrite: []string{"/tmp"}, }, Network: sandbox.NetworkPolicy{ AllowOutbound: []string{"registry.npmjs.org:443"}, }, } config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) args, err := translator.translate(policy) require.NoError(t, err) require.NotEmpty(t, args) // Convert to string for easier assertion argsStr := argSliceToString(args) // Essential system paths should be mounted read-only assert.Contains(t, argsStr, "--ro-bind-try") assert.Contains(t, argsStr, "/usr") assert.Contains(t, argsStr, "/lib") // Essential devices should be mounted assert.Contains(t, argsStr, "--dev-bind-try") assert.Contains(t, argsStr, "/dev/null") // Proc filesystem should be mounted assert.Contains(t, argsStr, "--proc") assert.Contains(t, argsStr, "/proc") // User-specified paths should be mounted assert.Contains(t, argsStr, "/tmp") // Network should be allowed (no --unshare-net) assert.NotContains(t, argsStr, "--unshare-net") // Process isolation should be enabled assert.Contains(t, argsStr, "--unshare-pid") assert.Contains(t, argsStr, "--unshare-ipc") // Die with parent assert.Contains(t, argsStr, "--die-with-parent") } func TestBubblewrapTranslatorFilesystemRules(t *testing.T) { cases := []struct { name string policy *sandbox.SandboxPolicy assert func(t *testing.T, args []string, err error) }{ { name: "simple read-only path", policy: &sandbox.SandboxPolicy{ Filesystem: sandbox.FilesystemPolicy{ AllowRead: []string{"/usr/local"}, }, }, assert: func(t *testing.T, args []string, err error) { require.NoError(t, err) argsStr := argSliceToString(args) // Should have read-only bind for the path assert.Contains(t, argsStr, "--ro-bind-try") assert.Contains(t, argsStr, "/usr/local") }, }, { name: "simple read-write path", policy: &sandbox.SandboxPolicy{ Filesystem: sandbox.FilesystemPolicy{ AllowWrite: []string{"/tmp/test"}, }, }, assert: func(t *testing.T, args []string, err error) { require.NoError(t, err) argsStr := argSliceToString(args) // Should have read-write bind for the path assert.Contains(t, argsStr, "--bind-try") assert.Contains(t, argsStr, "/tmp/test") }, }, { name: "variable expansion in paths", policy: &sandbox.SandboxPolicy{ Filesystem: sandbox.FilesystemPolicy{ AllowRead: []string{"${HOME}/.npmrc"}, AllowWrite: []string{"${CWD}/node_modules"}, }, }, assert: func(t *testing.T, args []string, err error) { require.NoError(t, err) argsStr := argSliceToString(args) homeDir, err := os.UserHomeDir() require.NoError(t, err) cwd, err := os.Getwd() require.NoError(t, err) // Variables should be expanded assert.Contains(t, argsStr, homeDir+"/.npmrc") assert.Contains(t, argsStr, cwd+"/node_modules") }, }, { name: "deny write with /dev/null mount", policy: &sandbox.SandboxPolicy{ Filesystem: sandbox.FilesystemPolicy{ DenyWrite: []string{"/etc/passwd"}, }, }, assert: func(t *testing.T, args []string, err error) { require.NoError(t, err) argsStr := argSliceToString(args) // Should mount /dev/null over denied path if it exists // Since /etc/passwd exists, it should be blocked if _, err := os.Stat("/etc/passwd"); err == nil { assert.Contains(t, argsStr, "--ro-bind") assert.Contains(t, argsStr, "/dev/null") assert.Contains(t, argsStr, "/etc/passwd") } }, }, { name: "multiple paths", policy: &sandbox.SandboxPolicy{ Filesystem: sandbox.FilesystemPolicy{ AllowRead: []string{ "/usr/bin", "/usr/lib", "/var/log", }, AllowWrite: []string{ "/tmp/output", "/var/tmp/cache", }, }, }, assert: func(t *testing.T, args []string, err error) { require.NoError(t, err) argsStr := argSliceToString(args) // All read paths should be present assert.Contains(t, argsStr, "/usr/bin") assert.Contains(t, argsStr, "/usr/lib") assert.Contains(t, argsStr, "/var/log") // All write paths should be present assert.Contains(t, argsStr, "/tmp/output") assert.Contains(t, argsStr, "/var/tmp/cache") }, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) args, err := translator.translate(tt.policy) tt.assert(t, args, err) }) } } func TestBubblewrapTranslatorGlobPatterns(t *testing.T) { // Create a temporary directory structure for testing glob expansion tmpDir := t.TempDir() // Create test files require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "subdir1"), 0755)) require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "subdir2"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("test"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "file2.log"), []byte("test"), 0644)) cases := []struct { name string policy *sandbox.SandboxPolicy assert func(t *testing.T, args []string, err error) }{ { name: "glob pattern with *", policy: &sandbox.SandboxPolicy{ Filesystem: sandbox.FilesystemPolicy{ AllowRead: []string{tmpDir + "/*.txt"}, }, }, assert: func(t *testing.T, args []string, err error) { require.NoError(t, err) argsStr := argSliceToString(args) // Should expand to concrete file assert.Contains(t, argsStr, "file1.txt") // Should NOT match .log files assert.NotContains(t, argsStr, "file2.log") }, }, { name: "glob pattern with ** (recursive)", policy: &sandbox.SandboxPolicy{ Filesystem: sandbox.FilesystemPolicy{ AllowWrite: []string{tmpDir + "/**"}, }, }, assert: func(t *testing.T, args []string, err error) { require.NoError(t, err) argsStr := argSliceToString(args) // Should include the base directory assert.Contains(t, argsStr, tmpDir) // Should include subdirectories assert.Contains(t, argsStr, "subdir1") assert.Contains(t, argsStr, "subdir2") }, }, { name: "non-existent glob pattern", policy: &sandbox.SandboxPolicy{ Filesystem: sandbox.FilesystemPolicy{ AllowRead: []string{"/nonexistent/path/**"}, }, }, assert: func(t *testing.T, args []string, err error) { require.NoError(t, err) argsStr := argSliceToString(args) // Should still include the base path (even if doesn't exist) assert.Contains(t, argsStr, "/nonexistent/path") }, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) args, err := translator.translate(tt.policy) tt.assert(t, args, err) }) } } func TestBubblewrapTranslatorNetworkIsolation(t *testing.T) { cases := []struct { name string policy *sandbox.SandboxPolicy assert func(t *testing.T, args []string, err error) }{ { name: "network allowed with allow rules", policy: &sandbox.SandboxPolicy{ Network: sandbox.NetworkPolicy{ AllowOutbound: []string{"registry.npmjs.org:443"}, }, }, assert: func(t *testing.T, args []string, err error) { require.NoError(t, err) argsStr := argSliceToString(args) // Should NOT have --unshare-net (network allowed) assert.NotContains(t, argsStr, "--unshare-net") }, }, { name: "network isolated with deny all", policy: &sandbox.SandboxPolicy{ Network: sandbox.NetworkPolicy{ DenyOutbound: []string{"*:*"}, }, }, assert: func(t *testing.T, args []string, err error) { require.NoError(t, err) argsStr := argSliceToString(args) // Should have --unshare-net (network denied) assert.Contains(t, argsStr, "--unshare-net") }, }, { name: "network isolated by default when no rules", policy: &sandbox.SandboxPolicy{ Network: sandbox.NetworkPolicy{}, }, assert: func(t *testing.T, args []string, err error) { require.NoError(t, err) argsStr := argSliceToString(args) // With default config (unshareNetworkByDefault: true), should isolate assert.Contains(t, argsStr, "--unshare-net") }, }, { name: "network allowed when config disables default isolation", policy: &sandbox.SandboxPolicy{ Network: sandbox.NetworkPolicy{}, }, assert: func(t *testing.T, args []string, err error) { require.NoError(t, err) // This test needs a custom config, so we can't assert here // Just verify no error }, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) args, err := translator.translate(tt.policy) tt.assert(t, args, err) }) } } func TestBubblewrapTranslatorPTYSupport(t *testing.T) { t.Run("PTY disabled by default", func(t *testing.T) { policy := &sandbox.SandboxPolicy{ AllowPTY: utils.PtrTo(false), } config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) args, err := translator.translate(policy) require.NoError(t, err) argsStr := argSliceToString(args) // Should NOT have PTY device bindings assert.NotContains(t, argsStr, "/dev/pts") assert.NotContains(t, argsStr, "/dev/ptmx") }) t.Run("PTY enabled when requested", func(t *testing.T) { policy := &sandbox.SandboxPolicy{ AllowPTY: utils.PtrTo(true), } config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) args, err := translator.translate(policy) require.NoError(t, err) argsStr := argSliceToString(args) // Should have PTY device bindings assert.Contains(t, argsStr, "/dev/pts") assert.Contains(t, argsStr, "/dev/ptmx") assert.Contains(t, argsStr, "--dev-bind-try") }) } func TestBubblewrapTranslatorMandatoryDenies(t *testing.T) { // Create temp directory with some dangerous files tmpDir := t.TempDir() sshDir := filepath.Join(tmpDir, ".ssh") require.NoError(t, os.MkdirAll(sshDir, 0700)) require.NoError(t, os.WriteFile(filepath.Join(sshDir, "id_rsa"), []byte("fake key"), 0600)) policy := &sandbox.SandboxPolicy{ Filesystem: sandbox.FilesystemPolicy{ // Even with broad write permissions... AllowWrite: []string{tmpDir + "/**"}, }, } config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) args, err := translator.translate(policy) require.NoError(t, err) argsStr := argSliceToString(args) // Mandatory deny patterns should be present // Note: The actual paths depend on the current working directory and home // We just verify that /dev/null mounting is used assert.Contains(t, argsStr, "--ro-bind") assert.Contains(t, argsStr, "/dev/null") } func TestBubblewrapTranslatorGitConfigDeny(t *testing.T) { t.Run("git config denied by default", func(t *testing.T) { policy := &sandbox.SandboxPolicy{ AllowGitConfig: utils.PtrTo(false), } config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) _, err := translator.translate(policy) require.NoError(t, err) // Git config should be in deny patterns // (we can't easily assert the exact args without creating a .git directory) }) t.Run("git config allowed when explicitly set", func(t *testing.T) { policy := &sandbox.SandboxPolicy{ AllowGitConfig: utils.PtrTo(true), } config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) _, err := translator.translate(policy) require.NoError(t, err) // Should succeed without adding git config to deny patterns }) } func TestBubblewrapConfigDefaults(t *testing.T) { config := newDefaultBubblewrapConfig() // Essential system paths assert.NotEmpty(t, config.essentialSystemPaths) assert.Contains(t, config.essentialSystemPaths, "/usr") assert.Contains(t, config.essentialSystemPaths, "/lib") // Essential devices assert.NotEmpty(t, config.essentialDevices) assert.Contains(t, config.essentialDevices, "/dev/null") assert.Contains(t, config.essentialDevices, "/dev/random") // Glob limits assert.Equal(t, 5, config.maxGlobDepth) assert.Equal(t, 10000, config.maxGlobPaths) // Isolation settings assert.True(t, config.unshareNetworkByDefault) assert.True(t, config.unsharePID) assert.True(t, config.unshareIPC) assert.True(t, config.dieWithParent) } func TestBubblewrapConfigEssentialPaths(t *testing.T) { config := newDefaultBubblewrapConfig() // Get essential system paths (filters out non-existent) paths := config.getEssentialSystemPaths() assert.NotEmpty(t, paths) // All returned paths should exist for _, path := range paths { _, err := os.Stat(path) assert.NoError(t, err, "Essential path %s should exist", path) } } func TestBubblewrapConfigEssentialDevices(t *testing.T) { config := newDefaultBubblewrapConfig() // Get essential devices (filters out non-existent) devices := config.getEssentialDevices() assert.NotEmpty(t, devices) // All returned devices should exist for _, device := range devices { _, err := os.Stat(device) assert.NoError(t, err, "Essential device %s should exist", device) } } func TestBubblewrapTranslatorProcessDenyRule(t *testing.T) { t.Run("existing file is blocked with /dev/null", func(t *testing.T) { tmpDir := t.TempDir() // Create a test file that exists testFile := filepath.Join(tmpDir, "existing.txt") require.NoError(t, os.WriteFile(testFile, []byte("test"), 0644)) policy := &sandbox.SandboxPolicy{ Filesystem: sandbox.FilesystemPolicy{ DenyWrite: []string{testFile}, }, } config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) args, err := translator.translate(policy) require.NoError(t, err) argsStr := argSliceToString(args) // Existing file should be mounted with /dev/null assert.Contains(t, argsStr, "--ro-bind") assert.Contains(t, argsStr, "/dev/null") assert.Contains(t, argsStr, testFile) }) t.Run("non-existent file is skipped to avoid creating empty files", func(t *testing.T) { tmpDir := t.TempDir() // Non-existent file - should be skipped because using --ro-bind /dev/null // on non-existent paths causes bwrap to create the file as a mount point nonExistentPath := filepath.Join(tmpDir, ".env") policy := &sandbox.SandboxPolicy{ Filesystem: sandbox.FilesystemPolicy{ AllowWrite: []string{tmpDir}, // Allow writes to tmpDir DenyWrite: []string{nonExistentPath}, }, } config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) args, err := translator.translate(policy) require.NoError(t, err) argsStr := argSliceToString(args) // Non-existent file should NOT be in args // Using --ro-bind /dev/null on non-existent paths creates empty files assert.NotContains(t, argsStr, nonExistentPath) }) t.Run("existing directory is mounted read-only", func(t *testing.T) { tmpDir := t.TempDir() // Create a directory testDir := filepath.Join(tmpDir, "secrets") require.NoError(t, os.MkdirAll(testDir, 0755)) policy := &sandbox.SandboxPolicy{ Filesystem: sandbox.FilesystemPolicy{ DenyWrite: []string{testDir}, }, } config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) args, err := translator.translate(policy) require.NoError(t, err) argsStr := argSliceToString(args) // Existing directory should be mounted read-only assert.Contains(t, argsStr, "--ro-bind-try") assert.Contains(t, argsStr, testDir) }) } func TestBubblewrapTranslatorTmpdirSupport(t *testing.T) { policy := &sandbox.SandboxPolicy{} config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) args, err := translator.translate(policy) require.NoError(t, err) argsStr := argSliceToString(args) // Tmpdir should be mounted as writable tmpDir := os.TempDir() assert.Contains(t, argsStr, "--bind") assert.Contains(t, argsStr, tmpDir) } func TestExpandGlobstarPattern(t *testing.T) { tmpDir := t.TempDir() // Create a directory structure require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "dir1", "subdir"), 0755)) require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "dir2"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "file.txt"), []byte("test"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "dir1", "file2.txt"), []byte("test"), 0644)) config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) cases := []struct { name string pattern string maxDepth int maxPaths int assert func(t *testing.T, matches []string, err error) }{ { name: "simple globstar", pattern: tmpDir + "/**", maxDepth: 3, maxPaths: 100, assert: func(t *testing.T, matches []string, err error) { require.NoError(t, err) assert.NotEmpty(t, matches) // Should include base directory assert.Contains(t, matches, tmpDir) }, }, { name: "globstar with depth limit", pattern: tmpDir + "/**", maxDepth: 1, maxPaths: 100, assert: func(t *testing.T, matches []string, err error) { require.NoError(t, err) // Should be limited by depth for _, match := range matches { rel, err := filepath.Rel(tmpDir, match) require.NoError(t, err) depth := len(filepath.SplitList(rel)) assert.LessOrEqual(t, depth, 2) // Base + 1 level } }, }, { name: "globstar with count limit", pattern: tmpDir + "/**", maxDepth: 10, maxPaths: 2, assert: func(t *testing.T, matches []string, err error) { require.NoError(t, err) // Should be limited by count assert.LessOrEqual(t, len(matches), 2) }, }, { name: "non-existent base path", pattern: "/nonexistent/path/**", maxDepth: 3, maxPaths: 100, assert: func(t *testing.T, matches []string, err error) { require.NoError(t, err) // Should return the base path even if it doesn't exist assert.Contains(t, matches, "/nonexistent/path") }, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { matches, err := translator.expandGlobstarPattern(tt.pattern, tt.maxDepth, tt.maxPaths) tt.assert(t, matches, err) }) } } func TestFindFirstNonExistentPath(t *testing.T) { tmpDir := t.TempDir() // Create a directory structure existingDir := filepath.Join(tmpDir, "existing") require.NoError(t, os.MkdirAll(existingDir, 0755)) config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) cases := []struct { name string path string expected string }{ { name: "file in existing directory", path: filepath.Join(existingDir, "nonexistent.txt"), expected: filepath.Join(existingDir, "nonexistent.txt"), }, { name: "nested non-existent path", path: filepath.Join(existingDir, "deep", "nested", "file.txt"), expected: filepath.Join(existingDir, "deep"), }, { name: "completely non-existent path", path: "/totally/nonexistent/path/file.txt", expected: "", // No parent exists, can't block creation }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { result := translator.findFirstNonExistentPath(tt.path) if tt.expected == "" { // For completely non-existent paths, we might get empty or a high-level path // Just verify no panic assert.True(t, true) } else { assert.Equal(t, tt.expected, result) } }) } } // TestGlobFallbackThreshold verifies coarse-grained fallback behavior when patterns match too many paths func TestGlobFallbackThreshold(t *testing.T) { tmpDir := t.TempDir() // Create 150 files (exceeds threshold of 100) for i := 0; i < 150; i++ { filePath := filepath.Join(tmpDir, fmt.Sprintf("file%d.txt", i)) require.NoError(t, os.WriteFile(filePath, []byte("test"), 0644)) } config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) policy := &sandbox.SandboxPolicy{ Name: "test-fallback", Filesystem: sandbox.FilesystemPolicy{ AllowRead: []string{tmpDir + "/*.txt"}, }, } args, err := translator.translate(policy) require.NoError(t, err) argsStr := argSliceToString(args) // Should bind parent directory (tmpDir), not individual files assert.Contains(t, argsStr, tmpDir) // Should NOT contain individual file paths (fallback to parent dir) assert.NotContains(t, argsStr, "file1.txt") assert.NotContains(t, argsStr, "file50.txt") assert.NotContains(t, argsStr, "file100.txt") // Verify total argument count is reasonable (coarse-grained fallback should prevent explosion) assert.Less(t, len(args), 300, "Coarse-grained fallback should prevent argument explosion") } // TestGlobFallbackThresholdGlobstar tests fallback with ** globstar patterns func TestGlobFallbackThresholdGlobstar(t *testing.T) { tmpDir := t.TempDir() // Create deep directory structure with 200 files (exceeds threshold) for i := 0; i < 10; i++ { subDir := filepath.Join(tmpDir, fmt.Sprintf("dir%d", i)) require.NoError(t, os.MkdirAll(subDir, 0755)) for j := 0; j < 20; j++ { filePath := filepath.Join(subDir, fmt.Sprintf("file%d.txt", j)) require.NoError(t, os.WriteFile(filePath, []byte("test"), 0644)) } } config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) policy := &sandbox.SandboxPolicy{ Name: "test-fallback-globstar", Filesystem: sandbox.FilesystemPolicy{ AllowWrite: []string{tmpDir + "/**"}, }, } args, err := translator.translate(policy) require.NoError(t, err) argsStr := argSliceToString(args) // Should bind parent directory (tmpDir) assert.Contains(t, argsStr, tmpDir) // Should NOT contain individual subdirectory paths (fallback to parent) assert.NotContains(t, argsStr, "dir1") assert.NotContains(t, argsStr, "dir5") // Verify total argument count is reasonable assert.Less(t, len(args), 300, "Coarse-grained fallback should prevent argument explosion") } // TestGlobNoFallbackSmallPattern tests that small patterns don't trigger fallback func TestGlobNoFallbackSmallPattern(t *testing.T) { tmpDir := t.TempDir() // Create only 10 files (below threshold) for i := 0; i < 10; i++ { filePath := filepath.Join(tmpDir, fmt.Sprintf("file%d.txt", i)) require.NoError(t, os.WriteFile(filePath, []byte("test"), 0644)) } config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) policy := &sandbox.SandboxPolicy{ Name: "test-no-fallback", Filesystem: sandbox.FilesystemPolicy{ AllowRead: []string{tmpDir + "/*.txt"}, }, } args, err := translator.translate(policy) require.NoError(t, err) argsStr := argSliceToString(args) // Should bind individual files (no fallback) assert.Contains(t, argsStr, "file0.txt") assert.Contains(t, argsStr, "file5.txt") } // TestTotalArgsLimit verifies global argument limit warning func TestTotalArgsLimit(t *testing.T) { // Create policy with many patterns that would exceed limit policy := &sandbox.SandboxPolicy{ Name: "test-args-limit", Filesystem: sandbox.FilesystemPolicy{ AllowRead: make([]string, 1000), // 1000 patterns }, } // Fill with literal paths to avoid glob expansion for i := 0; i < 1000; i++ { policy.Filesystem.AllowRead[i] = fmt.Sprintf("/tmp/path%d", i) } config := newDefaultBubblewrapConfig() config.totalArgsLimit = 500 // Set low for testing translator := newBubblewrapPolicyTranslator(config) args, err := translator.translate(policy) require.NoError(t, err) // Should not error, just warn // Verify args were generated despite exceeding limit assert.Greater(t, len(args), config.totalArgsLimit) } // TestExtractParentDir tests the extractParentDir helper function func TestExtractParentDir(t *testing.T) { cases := []struct { name string pattern string expected string }{ { name: "double star pattern", pattern: "/home/user/node_modules/**", expected: "/home/user/node_modules", }, { name: "single star pattern", pattern: "/tmp/*.txt", expected: "/tmp", }, { name: "middle glob", pattern: "/usr/lib/*.so", expected: "/usr/lib", }, { name: "complex glob", pattern: "/home/user/.cache/**/*.log", expected: "/home/user/.cache", }, { name: "file-level glob with dot", pattern: "/home/user/project/package.json.*", expected: "/home/user/project", }, { name: "file-level glob in CWD", pattern: "/home/user/project/*.lock", expected: "/home/user/project", }, { name: "question mark glob", pattern: "/tmp/file?.txt", expected: "/tmp", }, { name: "bracket glob", pattern: "/usr/lib/lib[abc].so", expected: "/usr/lib", }, { name: "no glob", pattern: "/home/user/file.txt", expected: "/home/user/file.txt", }, { name: "trailing double star", pattern: "/home/user/cache/**", expected: "/home/user/cache", }, { name: "trailing single star", pattern: "/var/log/*", expected: "/var/log", }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) result := translator.extractParentDir(tt.pattern) assert.Equal(t, tt.expected, result) }) } } // Helper function to convert arg slice to string for easier assertion func argSliceToString(args []string) string { result := "" for _, arg := range args { result += arg + " " } return result } func TestBubblewrapMandatoryDenySuppression(t *testing.T) { cwd, err := os.Getwd() require.NoError(t, err) t.Run("read-side opt-out preserves real ro-bind and skips tmpfs and /dev/null", func(t *testing.T) { // Real .env in an isolated CWD so processDenyRule does not skip the // path as non-existent. dir := t.TempDir() envPath := filepath.Join(dir, ".env") require.NoError(t, os.WriteFile(envPath, []byte("X=1\n"), 0o600)) origCwd, err := os.Getwd() require.NoError(t, err) require.NoError(t, os.Chdir(dir)) t.Cleanup(func() { _ = os.Chdir(origCwd) }) policy := &sandbox.SandboxPolicy{ Name: "test", Filesystem: sandbox.FilesystemPolicy{ AllowRead: []string{envPath}, }, } args := translateForTest(t, policy) assertNoTmpfsAt(t, args, envPath) // /dev/null overlay would mask reads; allow_read --ro-bind already // denies writes via EROFS, so the mandatory write deny is redundant. assertNoDevNullMount(t, args, envPath) assertReadBind(t, args, envPath) }) t.Run("user deny_write still wins for paths also in allow_read", func(t *testing.T) { dir := t.TempDir() envPath := filepath.Join(dir, ".env") require.NoError(t, os.WriteFile(envPath, []byte("X=1\n"), 0o600)) origCwd, err := os.Getwd() require.NoError(t, err) require.NoError(t, os.Chdir(dir)) t.Cleanup(func() { _ = os.Chdir(origCwd) }) policy := &sandbox.SandboxPolicy{ Name: "test", Filesystem: sandbox.FilesystemPolicy{ AllowRead: []string{envPath}, DenyWrite: []string{envPath}, }, } args := translateForTest(t, policy) assertDevNullMount(t, args, envPath) }) t.Run("write-side opt-out skips both tmpfs and /dev/null for that path", func(t *testing.T) { policy := &sandbox.SandboxPolicy{ Name: "test", Filesystem: sandbox.FilesystemPolicy{ AllowWrite: []string{filepath.Join(cwd, ".env")}, }, } args := translateForTest(t, policy) assertNoTmpfsAt(t, args, filepath.Join(cwd, ".env")) assertNoDevNullMount(t, args, filepath.Join(cwd, ".env")) }) t.Run("no opt-out: tmpfs fires for the path", func(t *testing.T) { // tmpfs only fires for paths that exist on the host; assert at the // GetMandatoryDenyPatterns level instead of the translator output. r := util.GetMandatoryDenyPatterns(util.MandatoryDenyOptions{}) assert.Contains(t, r.DenyRead, filepath.Join(cwd, ".env")) assert.Contains(t, r.DenyWrite, filepath.Join(cwd, ".env")) }) } func translateForTest(t *testing.T, policy *sandbox.SandboxPolicy) []string { t.Helper() tr := newBubblewrapPolicyTranslator(newDefaultBubblewrapConfig()) args, err := tr.translate(policy) require.NoError(t, err) return args } func assertNoTmpfsAt(t *testing.T, args []string, path string) { t.Helper() for i := 0; i+1 < len(args); i++ { if args[i] == "--tmpfs" && args[i+1] == path { t.Fatalf("expected no --tmpfs at %q, but found one", path) } } } func assertDevNullMount(t *testing.T, args []string, path string) { t.Helper() for i := 0; i+2 < len(args); i++ { if (args[i] == "--ro-bind" || args[i] == "--bind") && args[i+1] == "/dev/null" && args[i+2] == path { return } } t.Fatalf("expected /dev/null mount at %q, not found in args: %v", path, args) } func assertNoDevNullMount(t *testing.T, args []string, path string) { t.Helper() for i := 0; i+2 < len(args); i++ { if (args[i] == "--ro-bind" || args[i] == "--bind") && args[i+1] == "/dev/null" && args[i+2] == path { t.Fatalf("expected no /dev/null mount at %q, but found one", path) } } } func assertReadBind(t *testing.T, args []string, path string) { t.Helper() for i := 0; i+2 < len(args); i++ { if (args[i] == "--ro-bind" || args[i] == "--ro-bind-try") && args[i+1] == path && args[i+2] == path { return } } t.Fatalf("expected --ro-bind %q %q, not found in args: %v", path, path, args) }