Skip to content

Commit 6420879

Browse files
authored
Merge pull request NixOS#14296 from lovesegfault/nix-s3-more-tests
fix(nix-prefetch-url): correctly extract filename from URLs with query parameters
2 parents 67f5cb9 + 1b1d7e3 commit 6420879

File tree

4 files changed

+108
-7
lines changed

4 files changed

+108
-7
lines changed

src/libutil/include/nix/util/url.hh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,17 @@ struct VerbatimURL
408408
[](const ParsedURL & url) -> std::string_view { return url.scheme; }},
409409
raw);
410410
}
411+
412+
/**
413+
* Get the last non-empty path segment from the URL.
414+
*
415+
* This is useful for extracting filenames from URLs.
416+
* For example, "https://example.com/path/to/file.txt?query=value"
417+
* returns "file.txt".
418+
*
419+
* @return The last non-empty path segment, or std::nullopt if no such segment exists.
420+
*/
421+
std::optional<std::string> lastPathSegment() const;
411422
};
412423

413424
std::ostream & operator<<(std::ostream & os, const VerbatimURL & url);

src/libutil/url.cc

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include "nix/util/split.hh"
55
#include "nix/util/canon-path.hh"
66
#include "nix/util/strings-inline.hh"
7+
#include "nix/util/file-system.hh"
78

89
#include <boost/url.hpp>
910

@@ -440,4 +441,21 @@ std::ostream & operator<<(std::ostream & os, const VerbatimURL & url)
440441
return os;
441442
}
442443

444+
std::optional<std::string> VerbatimURL::lastPathSegment() const
445+
{
446+
try {
447+
auto parsedUrl = parsed();
448+
auto segments = parsedUrl.pathSegments(/*skipEmpty=*/true);
449+
if (std::ranges::empty(segments))
450+
return std::nullopt;
451+
return segments.back();
452+
} catch (BadURL &) {
453+
// Fall back to baseNameOf for unparsable URLs
454+
auto name = baseNameOf(to_string());
455+
if (name.empty())
456+
return std::nullopt;
457+
return std::string{name};
458+
}
459+
}
460+
443461
} // namespace nix

src/nix/prefetch.cc

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
#include "nix/cmd/misc-store-flags.hh"
1414
#include "nix/util/terminal.hh"
1515
#include "nix/util/environment-variables.hh"
16+
#include "nix/util/url.hh"
17+
#include "nix/store/path.hh"
1618

1719
#include "man-pages.hh"
1820

@@ -56,7 +58,7 @@ std::string resolveMirrorUrl(EvalState & state, const std::string & url)
5658

5759
std::tuple<StorePath, Hash> prefetchFile(
5860
ref<Store> store,
59-
std::string_view url,
61+
const VerbatimURL & url,
6062
std::optional<std::string> name,
6163
HashAlgorithm hashAlgo,
6264
std::optional<Hash> expectedHash,
@@ -68,9 +70,15 @@ std::tuple<StorePath, Hash> prefetchFile(
6870

6971
/* Figure out a name in the Nix store. */
7072
if (!name) {
71-
name = baseNameOf(url);
72-
if (name->empty())
73-
throw Error("cannot figure out file name for '%s'", url);
73+
name = url.lastPathSegment();
74+
if (!name || name->empty())
75+
throw Error("cannot figure out file name for '%s'", url.to_string());
76+
}
77+
try {
78+
checkName(*name);
79+
} catch (BadStorePathName & e) {
80+
e.addTrace({}, "file name '%s' was extracted from URL '%s'", *name, url.to_string());
81+
throw;
7482
}
7583

7684
std::optional<StorePath> storePath;
@@ -105,14 +113,14 @@ std::tuple<StorePath, Hash> prefetchFile(
105113

106114
FdSink sink(fd.get());
107115

108-
FileTransferRequest req(VerbatimURL{url});
116+
FileTransferRequest req(url);
109117
req.decompress = false;
110118
getFileTransfer()->download(std::move(req), sink);
111119
}
112120

113121
/* Optionally unpack the file. */
114122
if (unpack) {
115-
Activity act(*logger, lvlChatty, actUnknown, fmt("unpacking '%s'", url));
123+
Activity act(*logger, lvlChatty, actUnknown, fmt("unpacking '%s'", url.to_string()));
116124
auto unpacked = (tmpDir.path() / "unpacked").string();
117125
createDirs(unpacked);
118126
unpackTarfile(tmpFile.string(), unpacked);
@@ -128,7 +136,7 @@ std::tuple<StorePath, Hash> prefetchFile(
128136
}
129137
}
130138

131-
Activity act(*logger, lvlChatty, actUnknown, fmt("adding '%s' to the store", url));
139+
Activity act(*logger, lvlChatty, actUnknown, fmt("adding '%s' to the store", url.to_string()));
132140

133141
auto info = store->addToStoreSlow(
134142
*name, PosixSourceAccessor::createAtRoot(tmpFile), method, hashAlgo, {}, expectedHash);

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,69 @@ in
534534
535535
print(" ✓ No compression applied by default")
536536
537+
@setup_s3()
538+
def test_nix_prefetch_url(bucket):
539+
"""Test that nix-prefetch-url retrieves actual file content from S3, not empty files (issue #8862)"""
540+
print("\n=== Testing nix-prefetch-url S3 Content Retrieval (issue #8862) ===")
541+
542+
# Create a test file with known content
543+
test_content = "This is test content to verify S3 downloads work correctly!\n"
544+
test_file_size = len(test_content)
545+
546+
server.succeed(f"echo -n '{test_content}' > /tmp/test-file.txt")
547+
548+
# Upload to S3
549+
server.succeed(f"mc cp /tmp/test-file.txt minio/{bucket}/test-file.txt")
550+
551+
# Calculate expected hash
552+
expected_hash = server.succeed(
553+
"nix hash file --type sha256 --base32 /tmp/test-file.txt"
554+
).strip()
555+
556+
print(f" ✓ Uploaded test file to S3 ({test_file_size} bytes)")
557+
558+
# Use nix-prefetch-url to download from S3
559+
s3_url = make_s3_url(bucket, path="/test-file.txt")
560+
561+
prefetch_output = client.succeed(
562+
f"{ENV_WITH_CREDS} nix-prefetch-url --print-path '{s3_url}'"
563+
)
564+
565+
# Extract hash and store path
566+
# With --print-path, output is: <hash>\n<store-path>
567+
lines = prefetch_output.strip().split('\n')
568+
prefetch_hash = lines[0] # First line is the hash
569+
store_path = lines[1] # Second line is the store path
570+
571+
# Verify hash matches
572+
if prefetch_hash != expected_hash:
573+
raise Exception(
574+
f"Hash mismatch: expected {expected_hash}, got {prefetch_hash}"
575+
)
576+
577+
print(" ✓ nix-prefetch-url completed with correct hash")
578+
579+
# Verify the downloaded file is NOT empty (the bug in #8862)
580+
file_size = int(client.succeed(f"stat -c %s {store_path}").strip())
581+
582+
if file_size == 0:
583+
raise Exception("Downloaded file is EMPTY - issue #8862 regression detected!")
584+
585+
if file_size != test_file_size:
586+
raise Exception(
587+
f"File size mismatch: expected {test_file_size}, got {file_size}"
588+
)
589+
590+
print(f" ✓ File has correct size ({file_size} bytes, not empty)")
591+
592+
# Verify actual content matches by comparing hashes instead of printing entire file
593+
downloaded_hash = client.succeed(f"nix hash file --type sha256 --base32 {store_path}").strip()
594+
595+
if downloaded_hash != expected_hash:
596+
raise Exception(f"Content hash mismatch: expected {expected_hash}, got {downloaded_hash}")
597+
598+
print(" ✓ File content verified correct (hash matches)")
599+
537600
# ============================================================================
538601
# Main Test Execution
539602
# ============================================================================
@@ -562,6 +625,7 @@ in
562625
test_compression_narinfo_gzip()
563626
test_compression_mixed()
564627
test_compression_disabled()
628+
test_nix_prefetch_url()
565629
566630
print("\n" + "="*80)
567631
print("✓ All S3 Binary Cache Store Tests Passed!")

0 commit comments

Comments
 (0)