Skip to content

Commit 2016d41

Browse files
committed
feat(libstore): add public parameter for S3 stores to skip auth
When accessing public S3 buckets without credentials, the AWS SDK's credential provider chain attempts to contact the EC2 instance metadata service at 169.254.169.254. On non-AWS infrastructure (like local MinIO instances), this causes 30+ second timeouts before falling back to unauthenticated requests. This commit adds a `public` query parameter for S3 store URLs that tells Nix to skip all credential lookup attempts when set to true. This eliminates the timeout and improves performance for public bucket access. Usage: nix copy --from 's3://bucket?public=true&endpoint=...' /nix/store/...
1 parent a786c9e commit 2016d41

File tree

9 files changed

+219
-7
lines changed

9 files changed

+219
-7
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
synopsis: "S3 URLs now support skipping authentication for public buckets"
3+
prs: [14463]
4+
issues: [4857]
5+
---
6+
7+
S3 URLs now support a `public=true` query parameter that instructs Nix to skip
8+
all credential lookup attempts when accessing S3 buckets. This eliminates
9+
timeout delays when working with publicly accessible S3 buckets and improves
10+
reliability in environments where AWS credentials may be unavailable or
11+
misconfigured.
12+
13+
**Example usage:**
14+
15+
```bash
16+
# S3 binary cache store
17+
nix copy --from 's3://nix-cache?public=true&region=us-east-1' /nix/store/...
18+
```
19+
20+
```nix
21+
# fetchurl with public S3 URL
22+
builtins.fetchurl {
23+
url = "s3://public-bucket/file.tar.gz?public=true&region=us-east-1";
24+
sha256 = "...";
25+
}
26+
```
27+
28+
**Note:** The bucket must have appropriate public access policies configured on
29+
the S3 side. Nix will not attempt to verify permissions - requests will fail
30+
with HTTP 403 if the bucket is not publicly accessible.

src/libstore-tests/s3-url.cc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ INSTANTIATE_TEST_SUITE_P(
104104
},
105105
},
106106
"with_absolute_endpoint_uri",
107+
},
108+
ParsedS3URLTestCase{
109+
"s3://public-bucket/data.tar.gz?public=true",
110+
{
111+
.bucket = "public-bucket",
112+
.key = {"data.tar.gz"},
113+
.public_ = true,
114+
},
115+
"public_bucket_true",
107116
}),
108117
[](const ::testing::TestParamInfo<ParsedS3URLTestCase> & info) { return info.param.description; });
109118

src/libstore/filetransfer.cc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,12 @@ void FileTransferRequest::setupForS3()
885885
// Update the request URI to use HTTPS (works without AWS SDK)
886886
uri = parsedS3.toHttpsUrl();
887887

888+
// Skip authentication for public buckets
889+
if (parsedS3.public_) {
890+
debug("S3 request without authentication (marked as public bucket)");
891+
return;
892+
}
893+
888894
#if NIX_WITH_AWS_AUTH
889895
// Auth-specific code only compiled when AWS support is available
890896
awsSigV4Provider = "aws:amz:" + parsedS3.region.value_or("us-east-1") + ":s3";

src/libstore/include/nix/store/s3-binary-cache-store.hh

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,30 @@ struct S3BinaryCacheStoreConfig : HttpBinaryCacheStoreConfig
9393
Default is 100 MiB. Only takes effect when multipart-upload is enabled.
9494
)"};
9595

96+
const Setting<bool> public_{
97+
this,
98+
false,
99+
"public",
100+
R"(
101+
Whether to treat this S3 bucket as publicly accessible without authentication.
102+
When set to `true`, Nix will skip all credential lookup attempts, including
103+
checking EC2 instance metadata endpoints. This significantly improves performance
104+
when accessing public S3 buckets from non-AWS infrastructure.
105+
106+
> **Note**
107+
>
108+
> This setting should only be used with genuinely public buckets. Using it
109+
> with private buckets will result in access denied errors.
110+
)"};
111+
96112
/**
97113
* Set of settings that are part of the S3 URI itself.
98114
* These are needed for region specification and other S3-specific settings.
115+
*
116+
* @note The "public" parameter is a Nix-specific flag that controls authentication behavior,
117+
* telling Nix to skip credential lookup for public buckets to avoid timeouts.
99118
*/
100-
const std::set<const AbstractSetting *> s3UriSettings = {&profile, &region, &scheme, &endpoint};
119+
const std::set<const AbstractSetting *> s3UriSettings = {&profile, &region, &scheme, &endpoint, &public_};
101120

102121
static const std::string name()
103122
{

src/libstore/include/nix/store/s3-url.hh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ struct ParsedS3URL
3232
* or an authority (so an IP address or a registered name).
3333
*/
3434
std::variant<std::monostate, ParsedURL, ParsedURL::Authority> endpoint;
35+
bool public_ = false;
3536

3637
std::optional<std::string> getEncodedEndpoint() const
3738
{

src/libstore/s3-binary-cache-store.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,17 @@ For S3 compatible binary caches, consult that cache's documentation.
1010

1111
### Anonymous reads to your S3-compatible binary cache
1212

13-
> If your binary cache is publicly accessible and does not require authentication,
14-
> it is simplest to use the [HTTP Binary Cache Store] rather than S3 Binary Cache Store with
15-
> <https://example-nix-cache.s3.amazonaws.com> instead of <s3://example-nix-cache>.
13+
If your binary cache is publicly accessible and does not require authentication,
14+
you have two options:
15+
16+
1. Use the [HTTP Binary Cache Store] with <https://example-nix-cache.s3.amazonaws.com> instead of <s3://example-nix-cache>
17+
18+
2. Use the S3 Binary Cache Store with the `public=true` parameter:
19+
```
20+
s3://example-nix-cache?public=true
21+
```
22+
23+
The `public` parameter tells Nix to skip credential lookup attempts.
1624

1725
Your bucket will need a
1826
[bucket policy](https://docs.aws.amazon.com/AmazonS3/v1/userguide/bucket-policies.html)

src/libstore/s3-url.cc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ try {
3636
return it->second;
3737
};
3838

39+
auto getBooleanParam = [&](std::string_view key) -> bool {
40+
return getOptionalParam(key)
41+
.transform([](std::string_view val) { return val == "true" || val == "1"; })
42+
.value_or(false);
43+
};
44+
3945
auto endpoint = getOptionalParam("endpoint");
4046
if (parsed.path.size() <= 1 || !parsed.path.front().empty())
4147
throw BadURL("URI has a missing or invalid key");
@@ -61,6 +67,7 @@ try {
6167

6268
return ParsedURL::Authority::parse(*endpoint);
6369
}(),
70+
.public_ = getBooleanParam("public"),
6471
};
6572
} catch (BadURL & e) {
6673
e.addTrace({}, "while parsing S3 URI: '%s'", parsed.to_string());

src/libstore/unix/build/derivation-builder.cc

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -954,9 +954,16 @@ std::optional<AwsCredentials> DerivationBuilderImpl::preResolveAwsCredentials()
954954
try {
955955
auto parsedUrl = parseURL(url->second);
956956
if (parsedUrl.scheme == "s3") {
957-
debug("Pre-resolving AWS credentials for S3 URL in builtin:fetchurl");
958957
auto s3Url = ParsedS3URL::parse(parsedUrl);
959958

959+
// Skip credential pre-resolution for public buckets
960+
if (s3Url.public_) {
961+
debug("Skipping credential pre-resolution for public S3 bucket");
962+
return std::nullopt;
963+
}
964+
965+
debug("Pre-resolving AWS credentials for S3 URL in builtin:fetchurl");
966+
960967
// Use the preResolveAwsCredentials from aws-creds
961968
auto credentials = getAwsCredentialsProvider()->getCredentials(s3Url);
962969
debug("Successfully pre-resolved AWS credentials in parent process");

tests/nixos/s3-binary-cache-store.nix

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ in
364364
"""Test store operations on public bucket without credentials"""
365365
print("\n=== Testing Public Bucket Operations ===")
366366
367-
store_url = make_s3_url(bucket)
367+
store_url = make_s3_url(bucket, public='true')
368368
369369
# Verify store info works without credentials
370370
client.succeed(f"nix store info --store '{store_url}' >&2")
@@ -383,15 +383,139 @@ in
383383
verify_packages_in_store(client, [PKGS['A'], PKGS['B']], should_exist=False)
384384
385385
# Test copy from public bucket without credentials
386-
client.succeed(
386+
output = client.succeed(
387387
f"nix copy --debug --no-check-sigs "
388388
f"--from '{store_url}' {PKGS['A']} {PKGS['B']} 2>&1"
389389
)
390390
391+
# Verify the public flag is working (should see the debug message)
392+
if "S3 request without authentication (marked as public bucket)" not in output:
393+
print("Debug output:")
394+
print(output)
395+
raise Exception("Expected to see public bucket debug message")
396+
397+
# Verify no credential provider was created
398+
if "creating new AWS credential provider" in output:
399+
print("Debug output:")
400+
print(output)
401+
raise Exception("Should NOT create credential provider for public bucket")
402+
391403
# Verify packages were copied successfully
392404
verify_packages_in_store(client, [PKGS['A'], PKGS['B']])
393405
394406
print(" ✓ nix copy from public bucket works without credentials")
407+
print(" ✓ No credential lookup attempted (public=true flag working)")
408+
409+
@setup_s3(public=True)
410+
def test_fetchurl_public_bucket(bucket):
411+
"""Test that fetchurl of public S3 URL does not trigger credential attempts"""
412+
print("\n=== Testing fetchurl with Public S3 URL ===")
413+
414+
client.wait_for_unit("network-addresses-eth1.service")
415+
416+
# Upload a test file to the public bucket
417+
test_content = "Public S3 test file content for fetchurl\n"
418+
server.succeed(f"echo -n '{test_content}' > /tmp/public-test-file.txt")
419+
420+
# Calculate expected hash on server where file exists
421+
file_hash = server.succeed(
422+
"nix hash file --type sha256 --base32 /tmp/public-test-file.txt"
423+
).strip()
424+
425+
server.succeed(f"mc cp /tmp/public-test-file.txt minio/{bucket}/public-test.txt")
426+
427+
print(" ✓ Uploaded test file to public bucket")
428+
429+
# Test 1: builtins.fetchurl (immediate fetch in evaluator)
430+
# ======================================================
431+
s3_url = make_s3_url(bucket, path="/public-test.txt", public='true')
432+
433+
output = client.succeed(
434+
f"nix eval --debug --impure --expr "
435+
f"'builtins.fetchurl {{ name = \"public-s3-test\"; url = \"{s3_url}\"; }}' 2>&1"
436+
)
437+
438+
# Verify the public flag is working (should see the debug message)
439+
if "S3 request without authentication (marked as public bucket)" not in output:
440+
print("Debug output:")
441+
print(output)
442+
raise Exception("Expected to see public bucket debug message for fetchurl")
443+
444+
# Verify no credential provider was created
445+
if "creating new AWS credential provider" in output:
446+
print("Debug output:")
447+
print(output)
448+
raise Exception("fetchurl should NOT create credential provider for public S3 URL")
449+
450+
# Verify no credential pre-resolution happened (that's for private buckets only)
451+
if "Pre-resolving AWS credentials" in output:
452+
print("Debug output:")
453+
print(output)
454+
raise Exception("Should not attempt credential pre-resolution for public buckets")
455+
456+
print(" ✓ builtins.fetchurl works with public S3 URL")
457+
print(" ✓ No credential lookup attempted (public=true flag working)")
458+
print(" ✓ No credential pre-resolution attempted")
459+
460+
# Test 2: import <nix/fetchurl.nix> (fixed-output derivation with fork)
461+
# =====================================================================
462+
print("\n Testing import <nix/fetchurl.nix> with public S3 URL...")
463+
464+
# Build derivation with unique test ID (using hash calculated earlier)
465+
test_id = random.randint(0, 10000)
466+
test_url = make_s3_url(bucket, path="/public-test.txt", public='true', test_id=test_id)
467+
468+
fetchurl_expr = """
469+
import <nix/fetchurl.nix> {{
470+
name = "public-s3-fork-test-{id}";
471+
url = "{url}";
472+
sha256 = "{hash}";
473+
}}
474+
""".format(id=test_id, url=test_url, hash=file_hash)
475+
476+
build_output = client.succeed(
477+
f"nix build --debug --impure --no-link --expr '{fetchurl_expr}' 2>&1"
478+
)
479+
480+
# Verify fork behavior - should create fresh FileTransfer
481+
if "builtin:fetchurl creating fresh FileTransfer instance" not in build_output:
482+
print("Debug output:")
483+
print(build_output)
484+
raise Exception("Expected to find FileTransfer creation in forked process")
485+
486+
print(" ✓ Forked process creates fresh FileTransfer")
487+
488+
# Verify public bucket handling in forked process
489+
if "S3 request without authentication (marked as public bucket)" not in build_output:
490+
print("Debug output:")
491+
print(build_output)
492+
raise Exception("Expected to see public bucket debug message in forked process")
493+
494+
print(" ✓ Public bucket flag respected in forked process")
495+
496+
# Verify no credential provider was created (neither in parent nor child)
497+
if "creating new AWS credential provider" in build_output:
498+
print("Debug output:")
499+
print(build_output)
500+
raise Exception("Should NOT create credential provider for public S3 URL in fixed-output derivation")
501+
502+
print(" ✓ No credential provider created in parent or child process")
503+
504+
# Verify no credential pre-resolution happened
505+
# (public buckets should skip this entirely, unlike private buckets)
506+
if "Pre-resolving AWS credentials" in build_output:
507+
print("Debug output:")
508+
print(build_output)
509+
raise Exception("Should not attempt credential pre-resolution for public buckets")
510+
511+
if "Using pre-resolved AWS credentials from parent process" in build_output:
512+
print("Debug output:")
513+
print(build_output)
514+
raise Exception("Should not have pre-resolved credentials to use for public buckets")
515+
516+
print(" ✓ No credential pre-resolution attempted (public bucket optimization)")
517+
print("\n ✓ import <nix/fetchurl.nix> works with public S3 URL")
518+
print(" ✓ Fork + build path correctly skips all credential operations")
395519
396520
@setup_s3(populate_bucket=[PKGS['A']])
397521
def test_url_format_variations(bucket):
@@ -787,6 +911,7 @@ in
787911
test_fork_credential_preresolution()
788912
test_store_operations()
789913
test_public_bucket_operations()
914+
test_fetchurl_public_bucket()
790915
test_url_format_variations()
791916
test_concurrent_fetches()
792917
test_compression_narinfo_gzip()

0 commit comments

Comments
 (0)