From 2c792fd50eb05e071555504b335a61e6800945b2 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Tue, 23 Jun 2026 18:31:15 -0500 Subject: [PATCH 1/2] devpkg, shellgen: make the Nix binary cache configurable Devbox hardcodes https://cache.nixos.org as the binary cache it queries for prebuilt package outputs. This URL is used in two places: 1. The narinfo HEAD precheck in devpkg.readCaches / fetchNarInfoStatusFromHTTP, which runs unconditionally on every `devbox run` (via FillNarInfoCache). 2. The fromStore of the builtins.fetchClosure expressions in the generated flake.nix. In a network-restricted environment (e.g. an enterprise CI runner with no public egress) cache.nixos.org is unreachable, so the precheck HEAD request times out. Because fetchNarInfoStatusFromHTTP returns the error and fetchNarInfoStatusOnce propagates it, `devbox run` fails hard with `Head "https://cache.nixos.org/.narinfo": context deadline exceeded` even when an internal mirror that serves the identical store paths is available and configured as a Nix substituter. (Setting `substituters` in nix.conf does not help: builtins.fetchClosure contacts fromStore directly and ignores substituters.) Add a DEVBOX_NIX_BINARY_CACHE environment variable that overrides the default cache, following the same pattern as the existing DEVBOX_SEARCH_HOST override (envir.GetValueOrDefault). When unset, behavior is unchanged (https://cache.nixos.org), so this is fully backwards compatible. The override flows to both consumers: readCaches seeds its list with the resolved cache, and the flake template now sets fromStore to the http(s) cache the output was actually found in (via a new fetchClosureStore template helper), falling back to the configured binary cache for s3 caches that fetchClosure cannot use directly. Pointed at an internal mirror that proxies cache.nixos.org, `devbox run` then works with no public egress. --- internal/devpkg/narinfo_cache.go | 22 +++++-- internal/devpkg/narinfo_cache_test.go | 30 ++++++++++ internal/envir/env.go | 7 +++ internal/shellgen/generate.go | 24 +++++++- internal/shellgen/generate_internal_test.go | 64 +++++++++++++++++++++ internal/shellgen/tmpl/flake.nix.tmpl | 15 +++-- 6 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 internal/devpkg/narinfo_cache_test.go create mode 100644 internal/shellgen/generate_internal_test.go diff --git a/internal/devpkg/narinfo_cache.go b/internal/devpkg/narinfo_cache.go index e39fd2a6cfc..5c79300086c 100644 --- a/internal/devpkg/narinfo_cache.go +++ b/internal/devpkg/narinfo_cache.go @@ -15,15 +15,29 @@ import ( "github.com/pkg/errors" "go.jetify.com/devbox/internal/debug" "go.jetify.com/devbox/internal/devbox/providers/nixcache" + "go.jetify.com/devbox/internal/envir" "go.jetify.com/devbox/internal/goutil" "go.jetify.com/devbox/internal/lock" "go.jetify.com/devbox/internal/nix" "golang.org/x/sync/errgroup" ) -// binaryCache is the store from which to fetch this package's binaries. -// It is used as FromStore in builtins.fetchClosure. -const binaryCache = "https://cache.nixos.org" +// defaultBinaryCache is the public Nix binary cache that Devbox queries by +// default for prebuilt package outputs. +const defaultBinaryCache = "https://cache.nixos.org" + +// BinaryCache is the store from which to fetch a package's binaries. It is +// used as the FromStore in builtins.fetchClosure and as the first cache +// queried when checking whether a package's outputs are already built. +// +// It defaults to the public cache (https://cache.nixos.org) but can be +// overridden with the DEVBOX_NIX_BINARY_CACHE environment variable. This is +// useful in network-restricted environments where the public cache is +// unreachable and an internal mirror/proxy serving the same store paths must +// be used instead. +func BinaryCache() string { + return envir.GetValueOrDefault(envir.DevboxNixBinaryCache, defaultBinaryCache) +} // useDefaultOutputs is a special value for the outputName parameter of // fetchNarInfoStatusOnce, which indicates that the default outputs should be @@ -320,7 +334,7 @@ func fetchNarInfoStatusFromS3( var nixCacheIsConfigured = goutil.OnceValueWithContext(nixcache.IsConfigured) func readCaches(ctx context.Context) ([]string, error) { - cacheURIs := []string{binaryCache} + cacheURIs := []string{BinaryCache()} if !nixCacheIsConfigured.Do(ctx) { return cacheURIs, nil } diff --git a/internal/devpkg/narinfo_cache_test.go b/internal/devpkg/narinfo_cache_test.go new file mode 100644 index 00000000000..937f073ee7b --- /dev/null +++ b/internal/devpkg/narinfo_cache_test.go @@ -0,0 +1,30 @@ +// Copyright 2024 Jetify Inc. and contributors. All rights reserved. +// Use of this source code is governed by the license in the LICENSE file. + +package devpkg + +import ( + "testing" + + "go.jetify.com/devbox/internal/envir" +) + +func TestBinaryCache(t *testing.T) { + t.Run("defaults to the public cache", func(t *testing.T) { + // t.Setenv guarantees the env var is restored after the test; set it + // empty to ensure we exercise the default path regardless of the + // caller's environment. + t.Setenv(envir.DevboxNixBinaryCache, "") + if got, want := BinaryCache(), defaultBinaryCache; got != want { + t.Errorf("BinaryCache() = %q, want default %q", got, want) + } + }) + + t.Run("is overridable via DEVBOX_NIX_BINARY_CACHE", func(t *testing.T) { + const mirror = "https://nix-cache.example.com/mirror" + t.Setenv(envir.DevboxNixBinaryCache, mirror) + if got := BinaryCache(); got != mirror { + t.Errorf("BinaryCache() = %q, want override %q", got, mirror) + } + }) +} diff --git a/internal/envir/env.go b/internal/envir/env.go index 403daa3b12f..98494862ff0 100644 --- a/internal/envir/env.go +++ b/internal/envir/env.go @@ -11,6 +11,13 @@ const ( // the flag is awkward, such as a Dockerfile. DevboxConfig = "DEVBOX_CONFIG" DevboxGateway = "DEVBOX_GATEWAY" + // DevboxNixBinaryCache overrides the default Nix binary cache that Devbox + // queries for prebuilt package outputs (https://cache.nixos.org). This is + // useful in network-restricted environments where the public cache is + // unreachable and an internal mirror/proxy must be used instead. The value + // should be a substituter URL serving the same store paths (e.g. an + // Artifactory/Nexus generic remote that mirrors cache.nixos.org). + DevboxNixBinaryCache = "DEVBOX_NIX_BINARY_CACHE" // DevboxLatestVersion is the latest version available of the devbox CLI binary. // NOTE: it should NOT start with v (like 0.4.8) DevboxLatestVersion = "DEVBOX_LATEST_VERSION" diff --git a/internal/shellgen/generate.go b/internal/shellgen/generate.go index 838152f4c61..f1a0df3c049 100644 --- a/internal/shellgen/generate.go +++ b/internal/shellgen/generate.go @@ -17,6 +17,7 @@ import ( "github.com/pkg/errors" "go.jetify.com/devbox/internal/cuecfg" "go.jetify.com/devbox/internal/debug" + "go.jetify.com/devbox/internal/devpkg" "go.jetify.com/devbox/internal/redact" ) @@ -161,9 +162,26 @@ func toJSON(a any) string { } var templateFuncs = template.FuncMap{ - "json": toJSON, - "contains": strings.Contains, - "debug": debug.IsEnabled, + "json": toJSON, + "contains": strings.Contains, + "debug": debug.IsEnabled, + "fetchClosureStore": fetchClosureStore, +} + +// fetchClosureStore returns the store URL to use as the fromStore of a +// builtins.fetchClosure in the generated flake. +// +// fetchClosure only supports http(s) stores, so when a package's outputs were +// found in an http(s) cache we use that cache directly (it's where the path +// actually lives, which may be the public cache or a configured mirror via +// DEVBOX_NIX_BINARY_CACHE). For s3 caches fetchClosure can't fetch directly, so +// we fall back to the configured binary cache as a placeholder store — the path +// is already built locally, so fetchClosure won't actually fetch from it. +func fetchClosureStore(cacheURI string) string { + if strings.HasPrefix(cacheURI, "http://") || strings.HasPrefix(cacheURI, "https://") { + return cacheURI + } + return devpkg.BinaryCache() } func makeFlakeFile(d devboxer, plan *flakePlan) error { diff --git a/internal/shellgen/generate_internal_test.go b/internal/shellgen/generate_internal_test.go new file mode 100644 index 00000000000..0a03ed84b25 --- /dev/null +++ b/internal/shellgen/generate_internal_test.go @@ -0,0 +1,64 @@ +// Copyright 2024 Jetify Inc. and contributors. All rights reserved. +// Use of this source code is governed by the license in the LICENSE file. + +package shellgen + +import ( + "testing" + + "go.jetify.com/devbox/internal/envir" +) + +func TestFetchClosureStore(t *testing.T) { + const customCache = "https://nix-cache.example.com/mirror" + + tests := []struct { + name string + cacheURI string + envValue string + want string + }{ + { + name: "https cache uri is used directly", + cacheURI: "https://cache.nixos.org", + want: "https://cache.nixos.org", + }, + { + name: "http cache uri is used directly", + cacheURI: "http://nix-cache.example.com", + want: "http://nix-cache.example.com", + }, + { + name: "https mirror cache uri is used directly", + cacheURI: customCache, + want: customCache, + }, + { + name: "s3 cache falls back to default binary cache", + cacheURI: "s3://my-bucket", + want: "https://cache.nixos.org", + }, + { + name: "s3 cache falls back to configured binary cache", + cacheURI: "s3://my-bucket", + envValue: customCache, + want: customCache, + }, + { + name: "empty cache falls back to default binary cache", + cacheURI: "", + want: "https://cache.nixos.org", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + t.Setenv(envir.DevboxNixBinaryCache, tt.envValue) + } + if got := fetchClosureStore(tt.cacheURI); got != tt.want { + t.Errorf("fetchClosureStore(%q) = %q, want %q", tt.cacheURI, got, tt.want) + } + }) + } +} diff --git a/internal/shellgen/tmpl/flake.nix.tmpl b/internal/shellgen/tmpl/flake.nix.tmpl index 3bd0513ff4d..e2e91fe4587 100644 --- a/internal/shellgen/tmpl/flake.nix.tmpl +++ b/internal/shellgen/tmpl/flake.nix.tmpl @@ -42,13 +42,16 @@ {{ if $output.CacheURI -}} (builtins.trace "downloading {{ $pkg.Versioned }}" (builtins.fetchClosure { {{/* - HACK HACK HACK! fetchClosure only supports http(s) caches and not - s3 caches. Until we implement that, we put a fake store here. - Since we pre-build everything, fetchClosure will not actually - fetch anything and just use the local version. This may break - if user somehow removes the local store path. + fetchClosure only supports http(s) caches and not s3 caches. + fetchClosureStore returns the http(s) cache the output was found + in (the public cache or a configured mirror via + DEVBOX_NIX_BINARY_CACHE), falling back to the default binary + cache for s3 caches. Since we pre-build everything, fetchClosure + will not actually fetch anything for an s3 cache and just use the + local version. This may break if the user somehow removes the + local store path. */}} - fromStore = "https://cache.nixos.org"; + fromStore = "{{ fetchClosureStore $output.CacheURI }}"; fromPath = "{{ $pkg.InputAddressedPathForOutput $output.Name }}"; inputAddressed = true; })) From 8a66668081237a7aa41f19948a3e64f58d24c389 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Tue, 23 Jun 2026 18:47:24 -0500 Subject: [PATCH 2/2] shellgen: escape fromStore as a Nix string + isolate test env Address review feedback: - Render the fetchClosure fromStore through a nixString helper that escapes backslash, double quote, and the ${ antiquotation, so a configured cache URL (DEVBOX_NIX_BINARY_CACHE) with special characters can't break flake evaluation or be interpreted as Nix interpolation. Output is unchanged for ordinary URLs. - TestFetchClosureStore now always sets DEVBOX_NIX_BINARY_CACHE (empty when unset) so it can't be affected by the caller's environment. - Add TestNixString. --- internal/shellgen/generate.go | 15 +++++++ internal/shellgen/generate_internal_test.go | 45 +++++++++++++++++++-- internal/shellgen/tmpl/flake.nix.tmpl | 2 +- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/internal/shellgen/generate.go b/internal/shellgen/generate.go index f1a0df3c049..16c3052667f 100644 --- a/internal/shellgen/generate.go +++ b/internal/shellgen/generate.go @@ -166,6 +166,7 @@ var templateFuncs = template.FuncMap{ "contains": strings.Contains, "debug": debug.IsEnabled, "fetchClosureStore": fetchClosureStore, + "nixString": nixString, } // fetchClosureStore returns the store URL to use as the fromStore of a @@ -184,6 +185,20 @@ func fetchClosureStore(cacheURI string) string { return devpkg.BinaryCache() } +// nixString renders s as a double-quoted Nix string literal, escaping the +// characters that are special inside one: backslash, double quote, and the +// "${" antiquotation (interpolation) start. This keeps values that originate +// from configuration (e.g. a cache URL from DEVBOX_NIX_BINARY_CACHE) from +// breaking flake evaluation or being interpreted as Nix interpolation. +func nixString(s string) string { + r := strings.NewReplacer( + `\`, `\\`, + `"`, `\"`, + `${`, `\${`, + ) + return `"` + r.Replace(s) + `"` +} + func makeFlakeFile(d devboxer, plan *flakePlan) error { flakeDir := FlakePath(d) changed, err := writeFromTemplate(flakeDir, plan, "flake.nix", "flake.nix") diff --git a/internal/shellgen/generate_internal_test.go b/internal/shellgen/generate_internal_test.go index 0a03ed84b25..f11057f08e8 100644 --- a/internal/shellgen/generate_internal_test.go +++ b/internal/shellgen/generate_internal_test.go @@ -53,12 +53,51 @@ func TestFetchClosureStore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if tt.envValue != "" { - t.Setenv(envir.DevboxNixBinaryCache, tt.envValue) - } + // Always set the env var (empty when unset) so the test is + // isolated from the caller's environment: t.Setenv with an empty + // value still exercises the default-cache path via + // GetValueOrDefault, and restores any prior value afterward. + t.Setenv(envir.DevboxNixBinaryCache, tt.envValue) if got := fetchClosureStore(tt.cacheURI); got != tt.want { t.Errorf("fetchClosureStore(%q) = %q, want %q", tt.cacheURI, got, tt.want) } }) } } + +func TestNixString(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "plain url is just quoted", + in: "https://cache.nixos.org", + want: `"https://cache.nixos.org"`, + }, + { + name: "double quote is escaped", + in: `a"b`, + want: `"a\"b"`, + }, + { + name: "backslash is escaped", + in: `a\b`, + want: `"a\\b"`, + }, + { + name: "antiquotation start is escaped", + in: "a${b}", + want: `"a\${b}"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := nixString(tt.in); got != tt.want { + t.Errorf("nixString(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/shellgen/tmpl/flake.nix.tmpl b/internal/shellgen/tmpl/flake.nix.tmpl index e2e91fe4587..cbe1321aedf 100644 --- a/internal/shellgen/tmpl/flake.nix.tmpl +++ b/internal/shellgen/tmpl/flake.nix.tmpl @@ -51,7 +51,7 @@ local version. This may break if the user somehow removes the local store path. */}} - fromStore = "{{ fetchClosureStore $output.CacheURI }}"; + fromStore = {{ fetchClosureStore $output.CacheURI | nixString }}; fromPath = "{{ $pkg.InputAddressedPathForOutput $output.Name }}"; inputAddressed = true; }))