Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file.
### Fixed

- Launching a sandbox on a host with oh-my-zsh no longer aborts with `builder: ambiguous mount` (or a duplicate mount point on Docker). Both the `oh-my-zsh` and `shell-zsh` tools mounted `~/.oh-my-zsh`, and the second mount panicked before the workload started. The `oh-my-zsh` tool now mounts the framework alone, which also makes `[tools.oh-my-zsh] mount_mode = "disabled"` actually hide it instead of `shell-zsh` mounting it anyway.
- bwrap launches no longer abort with `bwrap: Can't mount on symlink destination /etc/ssl/certs` on distributions where `/etc/ssl/certs` is a symlink, such as the Fedora/RHEL family where it points at `/etc/pki/tls/certs`. bubblewrap refuses to mount over a symlink destination, so the CA directory is bound at its resolved target when the path is a symlink. On the Fedora/RHEL family `/etc/pki/tls` and `/etc/pki/ca-trust` are bound as well: the certificate files in `/etc/pki/tls/certs` are themselves symlinks into `/etc/pki/ca-trust/extracted/`, so without the latter the certificates stayed dangling and HTTPS failed with `curl: (77) error setting certificate file`. A CA path that exists but cannot be resolved now fails the launch instead of being skipped silently.

## [v0.22.0](https://github.com/zekker6/devsandbox/releases/tag/v0.22.0) - 2026-09-17

Expand Down
45 changes: 41 additions & 4 deletions internal/sandbox/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -473,14 +473,51 @@ func (b *Builder) AddLocaleBindings() *Builder {
}

func (b *Builder) AddCABindings() *Builder {
caPaths := []string{
return b.addCABindings([]string{
"/etc/ca-certificates",
"/etc/pki/tls/certs",
"/etc/pki/tls",
"/etc/pki/ca-trust",
"/etc/ssl/certs",
}
})
}

// addCABindings binds each CA directory read-only.
//
// A path whose final component is a symlink is bound at what it points to
// instead: bubblewrap refuses to mount over a symlink destination ("Can't mount
// on symlink destination"), and the symlink is already visible in the sandbox
// through its parent bind - AddNetworkBindings binds /etc/ssl whole. On Fedora,
// /etc/ssl/certs points at /etc/pki/tls/certs.
//
// Resolving the directory is not enough on its own: the certificate files
// *inside* it are symlinks too. On the Fedora/RHEL family /etc/pki/tls/certs
// holds ca-bundle.crt and the hashed *.0 names, all pointing into
// /etc/pki/ca-trust/extracted/, so the real store has to be bound as well or
// they dangle and curl fails with "error setting certificate file". /etc/pki/tls
// is bound in place of /etc/pki/tls/certs so cert.pem, a sibling of certs, comes
// along too.
//
// A path that exists but cannot be resolved is a build error, not a skip: the
// directory is one the launch is configured to bind, and silently dropping it
// would leave the sandbox without the certificates it promises.
func (b *Builder) addCABindings(caPaths []string) *Builder {
applied := make(map[string]bool, len(caPaths))
for _, p := range caPaths {
b.ROBindIfExists(p, p)
if _, err := os.Lstat(p); err != nil {
continue // CA directory this distribution does not have
}

resolved, err := resolveMountRulePath(p)
if err != nil {
b.err = fmt.Errorf("resolve CA path %q: %w", p, err)
return b
}

if applied[resolved] {
continue
}
applied[resolved] = true
b.ROBindIfExists(resolved, resolved)
}

return b
Expand Down
76 changes: 76 additions & 0 deletions internal/sandbox/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,82 @@ func TestBuilder_AddBaseArgs(t *testing.T) {
}
}

// TestAddCABindings_SymlinkedPathBindsResolvedTarget pins the fix for hosts
// where a CA directory is a symlink - Fedora has /etc/ssl/certs ->
// /etc/pki/tls/certs. bubblewrap refuses to mount over a symlink destination
// ("Can't mount on symlink destination"), and the symlink is already visible in
// the sandbox through its parent bind, so the resolved target is what has to be
// mounted or the link dangles.
func TestAddCABindings_SymlinkedPathBindsResolvedTarget(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "certs")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "ssl-certs")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}

b := NewBuilder(&Config{}).addCABindings([]string{link})

want := []string{"--ro-bind", target, target}
if got := b.Build(); !reflect.DeepEqual(got, want) {
t.Errorf("args = %v, want %v", got, want)
}
}

// TestAddCABindings_TargetListedOnce covers the Fedora layout, where the
// symlinked path and its target are both in the list: resolving the symlink must
// not emit a second mount of the target.
func TestAddCABindings_TargetListedOnce(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "certs")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "ssl-certs")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}

b := NewBuilder(&Config{}).addCABindings([]string{target, link})

want := []string{"--ro-bind", target, target}
if got := b.Build(); !reflect.DeepEqual(got, want) {
t.Errorf("args = %v, want %v", got, want)
}
}

// TestAddCABindings_MissingPathIsSkipped keeps the optional-path behavior: a CA
// directory this distribution does not have must not become a mount at all.
func TestAddCABindings_MissingPathIsSkipped(t *testing.T) {
missing := filepath.Join(t.TempDir(), "nope")

b := NewBuilder(&Config{}).addCABindings([]string{missing})

if got := b.Build(); len(got) != 0 {
t.Errorf("args = %v, want none", got)
}
}

// TestAddCABindings_BrokenSymlinkFailsBuild pins that a CA path which exists but
// cannot be resolved is reported instead of silently skipped. A skip would let a
// launch run without the certificate directory it was configured to bind.
func TestAddCABindings_BrokenSymlinkFailsBuild(t *testing.T) {
dir := t.TempDir()
link := filepath.Join(dir, "dangling")
if err := os.Symlink(filepath.Join(dir, "missing"), link); err != nil {
t.Fatal(err)
}

b := NewBuilder(&Config{}).addCABindings([]string{link})

if err := b.Err(); err == nil {
t.Fatal("expected an error for an unresolvable CA path, got nil")
}
}

func TestBuilder_OverlaySrc(t *testing.T) {
cfg := &Config{}
b := NewBuilder(cfg)
Expand Down
Loading