use camino::Utf8PathBuf;
use color_eyre::eyre::Result;
use rstest::rstest;
use super::fixture::{
CannedResponse,
artifact_for,
fixture_archive,
serve_once,
serve_sequence,
temp_root,
unreachable_url,
write_file,
};
use crate::{
error::BootstrapErrorKind,
extensions::{
ArchiveOrigin,
ManifestArtifact,
Sha256Hex,
archive::{CachedState, acquire, cached_state},
},
};
struct CacheCase {
_temp: tempfile::TempDir,
cache: Utf8PathBuf,
bytes: Vec<u8>,
artifact: ManifestArtifact,
}
fn cache_case(url: &str) -> Result<CacheCase> {
let (temp, root) = temp_root()?;
let bytes = fixture_archive()?;
let artifact = artifact_for(&bytes, "fixture.tar.gz", url);
Ok(CacheCase {
_temp: temp,
cache: root.join("cache"),
bytes,
artifact,
})
}
impl CacheCase {
fn entry_path(&self) -> Utf8PathBuf {
self.cache
.join(self.artifact.sha256.as_str())
.join("fixture.tar.gz")
}
fn seed(&self, bytes: &[u8]) -> Result<Utf8PathBuf> {
write_file(
&self.cache.join(self.artifact.sha256.as_str()),
"fixture.tar.gz",
bytes,
)
}
}
#[rstest]
#[case::valid_entry(true, false, ArchiveOrigin::Cached)]
#[case::corrupt_entry(false, true, ArchiveOrigin::Downloaded)]
fn acquire_uses_cache_or_redownloads(
#[case] seed_real_bytes: bool,
#[case] serve: bool,
#[case] expected: ArchiveOrigin,
) {
let bytes = fixture_archive().expect("fixture");
let url = if serve {
serve_once(bytes.clone()).expect("server")
} else {
unreachable_url().expect("port")
};
let case = cache_case(&url).expect("fixture");
let seed: &[u8] = if seed_real_bytes {
&case.bytes
} else {
b"corrupt"
};
case.seed(seed).expect("seed");
let acquired = acquire(&case.cache, &case.artifact).expect("acquired");
assert_eq!(acquired.origin, expected);
assert_eq!(acquired.path, case.entry_path());
assert_eq!(
Sha256Hex::of_file(&acquired.path).expect("hash"),
case.artifact.sha256
);
}
#[test]
fn acquire_rejects_digest_mismatch() {
let mut tampered = fixture_archive().expect("fixture");
tampered.push(0);
let mut case = cache_case(&serve_once(tampered).expect("server")).expect("fixture");
case.artifact.size += 1;
let err = acquire(&case.cache, &case.artifact).expect_err("mismatch");
assert_eq!(
err.kind(),
BootstrapErrorKind::ExtensionArchiveDigestMismatch
);
assert!(
!case.entry_path().exists(),
"a mismatching download must not be kept"
);
}
#[test]
fn acquire_reports_unreachable_download() {
let case = cache_case(&unreachable_url().expect("port")).expect("fixture");
let err = acquire(&case.cache, &case.artifact).expect_err("unreachable");
assert_eq!(err.kind(), BootstrapErrorKind::ExtensionArchiveUnavailable);
}
#[test]
fn acquire_retries_after_a_transient_server_error() {
let bytes = fixture_archive().expect("fixture");
let url = serve_sequence(vec![
CannedResponse::status("503 Service Unavailable"),
CannedResponse::ok(bytes),
])
.expect("server");
let case = cache_case(&url).expect("fixture");
let acquired = acquire(&case.cache, &case.artifact).expect("downloaded on retry");
assert_eq!(acquired.origin, ArchiveOrigin::Downloaded);
}
#[test]
fn acquire_does_not_retry_client_errors() {
let bytes = fixture_archive().expect("fixture");
let url = serve_sequence(vec![
CannedResponse::status("404 Not Found"),
CannedResponse::ok(bytes),
])
.expect("server");
let case = cache_case(&url).expect("fixture");
let err = acquire(&case.cache, &case.artifact).expect_err("404 is final");
assert_eq!(err.kind(), BootstrapErrorKind::ExtensionArchiveUnavailable);
assert!(err.to_string().contains("404"), "{err}");
}
#[test]
fn acquire_refuses_redirect_to_plain_http() {
let url = serve_sequence(vec![CannedResponse::redirect(
"http://example.invalid/fixture.tar.gz",
)])
.expect("server");
let case = cache_case(&url).expect("fixture");
let err = acquire(&case.cache, &case.artifact).expect_err("downgrade refused");
assert_eq!(err.kind(), BootstrapErrorKind::ExtensionArchiveUnavailable);
assert!(err.to_string().contains("redirect"), "{err}");
}
#[rstest]
#[case::https("https://github.com/x/y.tar.gz", true)]
#[case::loopback_v4("http://127.0.0.1:1234/x.tar.gz", true)]
#[case::loopback_v6("http://[::1]:1234/x.tar.gz", true)]
#[case::localhost("http://localhost/x.tar.gz", true)]
#[case::plain_http("http://example.com/x.tar.gz", false)]
#[case::ftp("ftp://example.com/x.tar.gz", false)]
#[case::garbage("not a url", false)]
fn permitted_url_rules(#[case] url: &str, #[case] expected: bool) {
assert_eq!(crate::extensions::is_permitted_url(url), expected);
}
#[rstest]
#[case::directory(true)]
#[case::regular_file(false)]
fn acquire_replaces_an_unusable_entry(#[case] as_directory: bool) {
let bytes = fixture_archive().expect("fixture");
let url = serve_once(bytes.clone()).expect("server");
let case = cache_case(&url).expect("fixture");
if as_directory {
std::fs::create_dir_all(case.entry_path()).expect("directory in place of an entry");
} else {
case.seed(b"corrupt").expect("seed");
}
let acquired = acquire(&case.cache, &case.artifact).expect("acquired");
assert_eq!(acquired.origin, ArchiveOrigin::Downloaded);
assert_eq!(
std::fs::read(&acquired.path).expect("read"),
case.bytes,
"the entry holds the downloaded archive"
);
}
#[rstest]
#[case::missing(EntryKind::Absent)]
#[case::valid(EntryKind::ExpectedBytes)]
#[case::corrupt(EntryKind::WrongBytes)]
#[case::unreadable(EntryKind::Directory)]
#[cfg_attr(unix, case::symlink(EntryKind::SymlinkToExpectedBytes))]
fn cache_entry_states_are_distinct(#[case] kind: EntryKind) {
let case = cache_case("https://example.invalid/fixture.tar.gz").expect("fixture");
let path = case.entry_path();
match kind {
EntryKind::Absent => {}
EntryKind::ExpectedBytes => {
case.seed(&case.bytes).expect("seed");
}
EntryKind::WrongBytes => {
case.seed(b"corrupt").expect("seed");
}
EntryKind::Directory => {
std::fs::create_dir_all(&path).expect("directory in place of an entry");
}
#[cfg(unix)]
EntryKind::SymlinkToExpectedBytes => {
let target = case.entry_path().with_extension("target");
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).expect("cache directory");
}
std::fs::write(&target, &case.bytes).expect("link target holds the archive");
symlink_to(&target, &path).expect("symlink in place of an entry");
}
}
let state = cached_state(&path, &case.artifact.sha256);
match (kind, &state) {
(EntryKind::Absent, CachedState::Missing)
| (EntryKind::ExpectedBytes, CachedState::Valid)
| (EntryKind::WrongBytes, CachedState::Corrupt)
| (EntryKind::Directory, CachedState::Unreadable(_)) => {}
#[cfg(unix)]
(EntryKind::SymlinkToExpectedBytes, CachedState::Unreadable(_)) => {}
(_, other) => panic!("{kind:?} was classified as {other:?}"),
}
}
#[derive(Debug, Clone, Copy)]
enum EntryKind {
Absent,
ExpectedBytes,
WrongBytes,
Directory,
#[cfg(unix)]
SymlinkToExpectedBytes,
}
#[cfg(unix)]
fn symlink_to(target: &Utf8PathBuf, link: &Utf8PathBuf) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[rstest]
#[case::userinfo(
"https://user:s3cr3t@example.invalid/x.tar.gz",
"https://example.invalid/x.tar.gz"
)]
#[case::signed_query(
"https://example.invalid/x.tar.gz?X-Amz-Signature=s3cr3t",
"https://example.invalid/x.tar.gz"
)]
#[case::fragment(
"https://example.invalid/x.tar.gz#s3cr3t",
"https://example.invalid/x.tar.gz"
)]
#[case::port(
"https://example.invalid:8443/x.tar.gz?t=s3cr3t",
"https://example.invalid:8443/x.tar.gz"
)]
#[case::unparsable("s3cr3t not a url", "<unparsable url>")]
fn redact_url_keeps_no_secret(#[case] raw: &str, #[case] expected: &str) {
let redacted = crate::extensions::redact_url(raw);
assert_eq!(redacted, expected);
assert!(!redacted.contains("s3cr3t"), "{redacted}");
}
#[rstest]
fn a_failed_download_reports_no_credential() {
let unreachable = unreachable_url().expect("port");
let with_secret = format!("{unreachable}?X-Amz-Signature=s3cr3t");
let case = cache_case(&with_secret).expect("fixture");
let err = acquire(&case.cache, &case.artifact).expect_err("unreachable");
let rendered = format!("{err} {err:?}");
assert!(!rendered.contains("s3cr3t"), "{rendered}");
assert_eq!(err.kind(), BootstrapErrorKind::ExtensionArchiveUnavailable);
}