diff --git a/CHANGELOG.md b/CHANGELOG.md index b7504d8..a689fb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/sandbox/builder.go b/internal/sandbox/builder.go index cb440dc..3aab943 100644 --- a/internal/sandbox/builder.go +++ b/internal/sandbox/builder.go @@ -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 diff --git a/internal/sandbox/builder_test.go b/internal/sandbox/builder_test.go index eb34e14..3c60c8d 100644 --- a/internal/sandbox/builder_test.go +++ b/internal/sandbox/builder_test.go @@ -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)