Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(grep:*)"
]
}
}
9 changes: 8 additions & 1 deletion src/libstore/http-binary-cache-store.cc
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,16 @@ void HttpBinaryCacheStore::getFile(const std::string & path, Sink & sink)
try {
getFileTransfer()->download(std::move(request), sink);
} catch (FileTransferError & e) {
if (e.error == FileTransfer::NotFound || e.error == FileTransfer::Forbidden)
if (e.error == FileTransfer::NotFound)
throw NoSuchBinaryCacheFile(
"file '%s' does not exist in binary cache '%s'", path, config->getHumanReadableURI());
if (e.error == FileTransfer::Forbidden)
throw NoSuchBinaryCacheFile(
"%s\nUnable to access file '%s' in binary cache '%s'. "
"If the binary cache requires authentication, check that your access token is valid and not expired.",
e.what(),
path,
config->getHumanReadableURI());
maybeDisable();
throw;
}
Expand Down
115 changes: 115 additions & 0 deletions tests/functional/binary-cache-auth-errors.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env bash

# Test that HTTP binary cache errors are properly distinguished:
# - 404 errors should say "does not exist in binary cache"
# - 401/403 errors should mention authentication and token expiration

source common.sh

TODO_NixOS

needLocalStore "'--no-require-sigs' can't be used with the daemon"

# Create a binary cache with some content
clearStore
clearCache
outPath=$(nix-build dependencies.nix --no-out-link)
nix copy --to "file://$cacheDir" "$outPath"

# Function to start the test HTTP server with a specific return code
TEST_SERVER="$_NIX_TEST_SOURCE_DIR/binary-cache-auth-errors_test-http-server.py"
startHttpServer() {
local return_code=$1
CACHE_DIR="$cacheDir" RETURN_CODE="$return_code" python3 "$TEST_SERVER" > "$TEST_ROOT/port" 2>&1 &
SERVER_PID=$!
sleep 1
HTTP_PORT=$(cat "$TEST_ROOT/port")
}

# Function to verify a pattern does NOT appear in a log file
expectNoMatch() {
local pattern=$1
local logfile=$2
local error_code=$3

if grep -q "$pattern" "$logfile"; then
echo "ERROR: $error_code error incorrectly says '$pattern'"
echo "Log contents:"
cat "$logfile"
exit 1
fi
}

cleanup() {
kill $SERVER_PID 2>/dev/null || true
}
trap cleanup EXIT

# Start the HTTP server on a random port
startHttpServer 200

# Test 1: Normal operation (200 OK) should work
clearStore
nix-store --substituters "http://127.0.0.1:$HTTP_PORT" --no-require-sigs -r "$outPath" 2>&1 | tee "$TEST_ROOT/log-200"
[ -x "$outPath/program" ]
expectNoMatch "does not exist in binary cache" "$TEST_ROOT/log-200" "200"
expectNoMatch "HTTP error" "$TEST_ROOT/log-200" "200"

# Stop the server and restart with 404 errors
kill $SERVER_PID
sleep 1

# Test 2: 404 errors should say "does not exist in binary cache"
clearStore
startHttpServer 404

if nix-store --substituters "http://127.0.0.1:$HTTP_PORT" --no-require-sigs -r "$outPath" 2>&1 | tee "$TEST_ROOT/log-404"; then
echo "Expected substitution to fail with 404"
exit 1
fi

echo "=== 404 Log contents ==="
cat "$TEST_ROOT/log-404"
echo "=== End 404 Log ==="

grepQuiet "does not exist in binary cache" "$TEST_ROOT/log-404"

# Stop the server and restart with 401 errors
kill $SERVER_PID
sleep 1

# Test 3: 401 errors should mention authentication and NOT say "does not exist"
clearStore
startHttpServer 401

if nix-store --substituters "http://127.0.0.1:$HTTP_PORT" --no-require-sigs -r "$outPath" 2>&1 | tee "$TEST_ROOT/log-401"; then
echo "Expected substitution to fail with 401"
exit 1
fi

grepQuiet "HTTP error 401" "$TEST_ROOT/log-401"
grepQuiet "access token" "$TEST_ROOT/log-401"

# Verify it does NOT say "does not exist" for 401 errors
expectNoMatch "does not exist in binary cache" "$TEST_ROOT/log-401" "401"

# Stop the server and restart with 403 errors
kill $SERVER_PID
sleep 1

# Test 4: 403 errors should also mention authentication and NOT say "does not exist"
clearStore
startHttpServer 403

if nix-store --substituters "http://127.0.0.1:$HTTP_PORT" --no-require-sigs -r "$outPath" 2>&1 | tee "$TEST_ROOT/log-403"; then
echo "Expected substitution to fail with 403"
exit 1
fi

grepQuiet "HTTP error 403" "$TEST_ROOT/log-403"
grepQuiet "access token" "$TEST_ROOT/log-403"

# Verify it does NOT say "does not exist" for 403 errors
expectNoMatch "does not exist in binary cache" "$TEST_ROOT/log-403" "403"

echo "All HTTP error tests passed!"
63 changes: 63 additions & 0 deletions tests/functional/binary-cache-auth-errors_test-http-server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""
Test HTTP server for binary cache authentication error testing.

This server serves files from a directory but can be configured to return
specific HTTP status codes (401, 403, 404) for NAR files while still serving
metadata files (.narinfo, nix-cache-info) normally.

Environment variables:
- CACHE_DIR: Directory to serve files from
- RETURN_CODE: HTTP status code to return for NAR files (default: 200)
- TEST_SERVER_PORT: Port to bind to (0 = random port, default)
"""

import http.server
import socketserver
import os
import sys
from urllib.parse import urlparse, parse_qs

PORT = int(os.environ.get('TEST_SERVER_PORT', 0))
CACHE_DIR = os.environ.get('CACHE_DIR', '')
RETURN_CODE = int(os.environ.get('RETURN_CODE', 200))

class TestHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
# Remove leading slash
path = self.path.lstrip('/')
file_path = os.path.join(CACHE_DIR, path)

# Check if we should return an error for NAR files
# Always serve nix-cache-info and .narinfo files normally
if RETURN_CODE != 200 and (path.startswith('nar/') or path.endswith('.nar') or path.endswith('.nar.xz')):
self.send_response(RETURN_CODE)
if RETURN_CODE == 401:
self.send_header('WWW-Authenticate', 'Bearer realm="test"')
self.send_header('Content-Length', '0')
self.end_headers()
return

# Otherwise, serve files from the cache directory
if os.path.isfile(file_path):
self.send_response(200)
self.send_header('Content-Type', 'application/octet-stream')
with open(file_path, 'rb') as f:
content = f.read()
self.send_header('Content-Length', str(len(content)))
self.end_headers()
self.wfile.write(content)
else:
self.send_response(404)
self.send_header('Content-Length', '0')
self.end_headers()

def log_message(self, format, *args):
# Suppress logging
pass

# Bind to a random port if PORT is 0
with socketserver.TCPServer(("127.0.0.1", PORT), TestHandler) as httpd:
# Print the actual port we're listening on
print(httpd.server_address[1], flush=True)
httpd.serve_forever()
1 change: 1 addition & 0 deletions tests/functional/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ suites = [
'user-envs.sh',
'user-envs-migration.sh',
'binary-cache.sh',
'binary-cache-auth-errors.sh',
'multiple-outputs.sh',
'nix-build.sh',
'gc-concurrent.sh',
Expand Down
Loading