mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add support for Linux Sandbox using Bubblewrap (#120)
* feat: Add support for bubblewrap sandbox * fix: Glob pattern expansion limit for linux * fix: Bug in glob pattern expansion for bwrap * fix: README on trust * fix: Multiple bubblewrap translator fix * test: Add E2E for linux sandbox * fix: Refactor bwrap sandbox to use common dangerous files * fix: Path test case * fix: Non-existent path handling bug * refactor: Misc cleanup * fix: Avoid bind mount for non-existentent deny protection * fix: Off by one bug in path depth handling * ci: Disable AppArmor on GHA runner * fix: Disable apparmor userns restrictions
This commit is contained in:
@@ -0,0 +1,917 @@
|
||||
//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/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
|
||||
}
|
||||
Reference in New Issue
Block a user