Files
pmg/sandbox/platform/bubblewrap_translator_linux_test.go
T
Sahil BansalandGitHub 872c5d663c fix(sandbox): bind parent dir for globstar allow_write on bwrap (#321)
* fix(sandbox): bind parent dir for globstar allow_write on bwrap

Fine-grained per-path mounts under read-only project binds broke pip
install into in-project .venv directories. Always mount the parent tree
for ** write rules instead.

Fixes #315

* test(sandbox): tighten globstar bind assertions and ensure ~/.npm exists for e2e

Strengthen TestBubblewrapAllowWriteGlobstarBindsParentOnly to verify the
parent dir is writably bound and the child path is read-only bound, not
just substring presence. Pre-create ~/.npm in the e2e harness so
bubblewrap --bind-try does not skip the npm cache dir on fresh runners.

* switch pnpm to /tmp in sandbox e2e

* test(sandbox): update glob ** test for parent-bind semantics

Globstar allow_write now binds the parent dir only (e2e740d), so the
test should assert the parent is writably bound and child subdirs are
not individually bound, instead of substring-matching subdir names.

* fix(sandbox): bind correct base dir for in-pattern globstar allow_write

Globstar allow_write previously used extractGlobParentDir, which walks past
the first ** and yields the wrong root for patterns like /a/b/**/d/**/e.
Introduce extractGlobstarWriteBaseDir, which takes the prefix before the
first /**, and use it in processWriteRule. Also dedup the coarse-fallback
parent-bind loop to mirror the read-rule fallback.
2026-06-07 10:03:23 +05:30

1177 lines
34 KiB
Go

//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, policy *sandbox.SandboxPolicy)
}{
{
name: "simple read-only path",
policy: &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/usr/local"},
},
},
assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) {
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, policy *sandbox.SandboxPolicy) {
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, policy *sandbox.SandboxPolicy) {
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 read-only bind",
policy: &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
DenyWrite: []string{"/etc/passwd"},
},
},
assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) {
require.NoError(t, err)
if _, err := os.Stat("/etc/passwd"); err == nil {
assertNoDevNullMount(t, args, "/etc/passwd")
assertReadBind(t, args, "/etc/passwd")
}
},
},
{
name: "deny read masks file with dev null",
policy: func() *sandbox.SandboxPolicy {
dir := t.TempDir()
path := filepath.Join(dir, "secret.txt")
require.NoError(t, os.WriteFile(path, []byte("secret"), 0o600))
return &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
DenyRead: []string{path},
},
}
}(),
assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) {
require.NoError(t, err)
assertDevNullMount(t, args, policy.Filesystem.DenyRead[0])
},
},
{
name: "deny read hides directory with tmpfs",
policy: func() *sandbox.SandboxPolicy {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("secret"), 0o600))
return &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
DenyRead: []string{dir},
},
}
}(),
assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) {
require.NoError(t, err)
assertTmpfsAt(t, args, policy.Filesystem.DenyRead[0])
},
},
{
name: "deny read globstar uses parent directory approximation",
policy: func() *sandbox.SandboxPolicy {
dir := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(dir, "nested"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(dir, "nested", "secret.txt"), []byte("secret"), 0o600))
return &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
DenyRead: []string{filepath.Join(dir, "**")},
},
}
}(),
assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) {
require.NoError(t, err)
parentDir := filepath.Dir(policy.Filesystem.DenyRead[0])
assertTmpfsAt(t, args, parentDir)
assertNoDevNullMount(t, args, filepath.Join(parentDir, "nested", "secret.txt"))
},
},
{
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, policy *sandbox.SandboxPolicy) {
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, tt.policy)
})
}
}
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)
assertWriteBind(t, args, tmpDir)
assertNoWriteBind(t, args, filepath.Join(tmpDir, "subdir1"))
assertNoWriteBind(t, args, filepath.Join(tmpDir, "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 TestBubblewrapTranslatorProcessDenyWriteRule(t *testing.T) {
t.Run("existing file is mounted read-only", 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)
assertNoDevNullMount(t, args, testFile)
assertReadBind(t, args, 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 bwrap requires a real
// mount target for file-level read-only bind overrides.
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)
})
}
}
// 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")
}
// TestBubblewrapAllowWriteGlobstarBindsParentOnly verifies globstar allow_write rules
// bind the parent tree (e.g. .venv) instead of per-file mounts that break pip in-project venvs.
// See https://github.com/safedep/pmg/issues/315
func TestBubblewrapAllowWriteGlobstarBindsParentOnly(t *testing.T) {
tmpDir := t.TempDir()
venvDir := filepath.Join(tmpDir, ".venv")
binDir := filepath.Join(venvDir, "bin")
require.NoError(t, os.MkdirAll(binDir, 0755))
pythonPath := filepath.Join(binDir, "python")
require.NoError(t, os.Symlink("/usr/bin/python3", pythonPath))
// Populate enough shallow files that old logic would fine-grain bind without hitting fallback.
for i := 0; i < 30; i++ {
path := filepath.Join(venvDir, fmt.Sprintf("file%d.txt", i))
require.NoError(t, os.WriteFile(path, []byte("x"), 0644))
}
config := newDefaultBubblewrapConfig()
translator := newBubblewrapPolicyTranslator(config)
policy := &sandbox.SandboxPolicy{
Name: "test-venv-write",
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{tmpDir + "/**"},
AllowWrite: []string{venvDir + "/**"},
},
}
args, err := translator.translate(policy)
require.NoError(t, err)
assertWriteBind(t, args, venvDir)
assertNoWriteBind(t, args, pythonPath)
assertReadBind(t, args, pythonPath)
}
// 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)
})
}
}
func TestExtractGlobstarWriteBaseDir(t *testing.T) {
cases := []struct {
name string
pattern string
expected string
}{
{
name: "suffix globstar (profiles)",
pattern: "/home/user/.venv/**",
expected: "/home/user/.venv",
},
{
name: "in-pattern globstars",
pattern: "/a/b/**/d/**/e",
expected: "/a/b",
},
{
name: "middle globstar with file suffix",
pattern: "/usr/lib/**/*.so",
expected: "/usr/lib",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, extractGlobstarWriteBaseDir(tt.pattern))
})
}
}
func TestBubblewrapGlobstarWriteMultiSegmentBind(t *testing.T) {
tmpDir := t.TempDir()
baseDir := filepath.Join(tmpDir, "a", "b")
require.NoError(t, os.MkdirAll(baseDir, 0755))
pattern := filepath.Join(tmpDir, "a", "b", "**", "d", "**", "e")
config := newDefaultBubblewrapConfig()
translator := newBubblewrapPolicyTranslator(config)
policy := &sandbox.SandboxPolicy{
Name: "test-multi-globstar-write",
Filesystem: sandbox.FilesystemPolicy{
AllowWrite: []string{pattern},
},
}
args, err := translator.translate(policy)
require.NoError(t, err)
assertWriteBind(t, args, baseDir)
}
// 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 preserves read 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},
AllowWrite: []string{dir},
DenyWrite: []string{envPath},
},
}
args := translateForTest(t, policy)
assertNoDevNullMount(t, args, envPath)
assertReadOnlyBindAfterWritableBind(t, args, envPath, dir)
})
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 assertTmpfsAt(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 {
return
}
}
t.Fatalf("expected --tmpfs at %q, but none found", path)
}
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 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+1] == "/dev/null" && args[i+2] == path {
return
}
}
t.Fatalf("expected /dev/null mount at %q, but none found", 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)
}
func assertWriteBind(t *testing.T, args []string, path string) {
t.Helper()
for i := 0; i+2 < len(args); i++ {
if (args[i] == "--bind" || args[i] == "--bind-try") && args[i+1] == path && args[i+2] == path {
return
}
}
t.Fatalf("expected --bind-try %q %q, not found in args: %v", path, path, args)
}
func assertNoWriteBind(t *testing.T, args []string, path string) {
t.Helper()
for i := 0; i+2 < len(args); i++ {
if (args[i] == "--bind" || args[i] == "--bind-try") && args[i+1] == path && args[i+2] == path {
t.Fatalf("unexpected writable bind at %q in args: %v", path, args)
}
}
}
func assertReadOnlyBindAfterWritableBind(t *testing.T, args []string, readOnlyPath string, writablePath string) {
t.Helper()
writableBindIndex := -1
readOnlyBindIndex := -1
for i := 0; i+2 < len(args); i++ {
if (args[i] == "--bind" || args[i] == "--bind-try") && args[i+1] == writablePath && args[i+2] == writablePath {
writableBindIndex = i
}
if (args[i] == "--ro-bind" || args[i] == "--ro-bind-try") && args[i+1] == readOnlyPath && args[i+2] == readOnlyPath {
readOnlyBindIndex = i
}
}
require.NotEqual(t, -1, writableBindIndex, "expected writable bind for %q in args: %v", writablePath, args)
require.NotEqual(t, -1, readOnlyBindIndex, "expected read-only bind for %q in args: %v", readOnlyPath, args)
assert.Greater(t, readOnlyBindIndex, writableBindIndex, "deny_write read-only bind must override earlier writable parent bind")
}