diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000000..4a1edf13a3d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(grep:*)" + ] + } +} diff --git a/src/libstore/http-binary-cache-store.cc b/src/libstore/http-binary-cache-store.cc index d4361264edf..e93147be69b 100644 --- a/src/libstore/http-binary-cache-store.cc +++ b/src/libstore/http-binary-cache-store.cc @@ -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; } diff --git a/tests/functional/binary-cache-auth-errors.sh b/tests/functional/binary-cache-auth-errors.sh new file mode 100755 index 00000000000..8b725f223e2 --- /dev/null +++ b/tests/functional/binary-cache-auth-errors.sh @@ -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!" diff --git a/tests/functional/binary-cache-auth-errors_test-http-server.py b/tests/functional/binary-cache-auth-errors_test-http-server.py new file mode 100755 index 00000000000..278d6e55245 --- /dev/null +++ b/tests/functional/binary-cache-auth-errors_test-http-server.py @@ -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() diff --git a/tests/functional/meson.build b/tests/functional/meson.build index d917d91c3f3..aaa3530c016 100644 --- a/tests/functional/meson.build +++ b/tests/functional/meson.build @@ -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',