//go:build linux // +build linux package platform import ( "os" "path/filepath" "testing" "github.com/safedep/dry/utils" "github.com/safedep/pmg/sandbox" "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) // Mandatory deny patterns assert.NotEmpty(t, config.mandatoryDenyPatterns) assert.Contains(t, config.mandatoryDenyPatterns, ".env") assert.Contains(t, config.mandatoryDenyPatterns, ".ssh") } 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) { tmpDir := t.TempDir() // Create a test file that exists testFile := filepath.Join(tmpDir, "existing.txt") require.NoError(t, os.WriteFile(testFile, []byte("test"), 0644)) // Create a path that doesn't exist nonExistentPath := filepath.Join(tmpDir, ".env") policy := &sandbox.SandboxPolicy{ Filesystem: sandbox.FilesystemPolicy{ DenyWrite: []string{ testFile, // Existing file nonExistentPath, // Non-existent file }, }, } 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) // Non-existent file should also be blocked assert.Contains(t, argsStr, nonExistentPath) } 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) } }) } } // 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 }