use rstest::rstest;
use super::{
fixture::Entry,
install::{fixture_entries, install, prepared},
};
use crate::{
error::BootstrapErrorKind,
extensions::install::{
ARCHIVE_DECOMPRESSED_CAP,
ENTRY_DECOMPRESSED_CAP,
check_decompressed_caps,
},
};
#[cfg(unix)]
#[test]
fn a_symlinked_parent_component_is_refused() {
let prepared = prepared(&fixture_entries()).expect("fixture");
let escape = prepared
.install_dir
.parent()
.expect("install tree has a parent")
.join("escape");
std::fs::create_dir(&escape).expect("escape directory");
let lib = prepared.install_dir.join("lib");
std::fs::remove_dir_all(&lib).expect("clear lib");
std::os::unix::fs::symlink(escape.as_std_path(), lib.as_std_path()).expect("symlink lib");
let err = install(&prepared).expect_err("a symlinked parent must be refused");
assert_eq!(
err.kind(),
BootstrapErrorKind::ExtensionInstallFailed,
"{err}"
);
assert!(
!escape.join("fixture.so").exists(),
"nothing may be installed through the symlink"
);
assert!(
std::fs::read_dir(&escape)
.expect("read escape")
.next()
.is_none(),
"the escape directory must stay empty"
);
}
#[test]
fn install_refuses_an_entry_that_decompresses_past_the_cap() {
let oversized: &'static [u8] = Vec::leak(vec![
0_u8;
usize::try_from(ENTRY_DECOMPRESSED_CAP)
.expect("cap fits")
+ 1
]);
let entries = [
Entry::File("lib/fixture.so", oversized),
Entry::File(
"share/extension/fixture.control",
b"default_version = '1'\n",
),
];
let mut prepared = prepared(&entries).expect("fixture");
prepared.artifact.files = vec![
"lib/fixture.so".to_owned(),
"share/extension/fixture.control".to_owned(),
];
let compressed = std::fs::metadata(&prepared.archive)
.expect("archive metadata")
.len();
assert!(
compressed < ENTRY_DECOMPRESSED_CAP,
"the fixture must pass the compressed-size checks to be a fair test, was {compressed}"
);
let err = install(&prepared).expect_err("an entry over the cap is refused");
assert_eq!(err.kind(), BootstrapErrorKind::ExtensionArchiveInvalid);
assert!(
err.to_string().contains("per-file limit"),
"the refusal must name the per-file cap: {err}"
);
for relative in ["lib/fixture.so", "share/extension/fixture.control"] {
assert!(
!prepared.install_dir.join(relative).exists(),
"{relative} must not be written: the cap is enforced before pass two"
);
}
}
#[rstest]
#[case::first_entry_fits(0, 1, Ok(1))]
#[case::entry_at_the_cap(0, ENTRY_DECOMPRESSED_CAP, Ok(ENTRY_DECOMPRESSED_CAP))]
#[case::entry_over_the_cap(0, ENTRY_DECOMPRESSED_CAP + 1, Err("per-file limit"))]
#[case::total_at_the_cap(
ARCHIVE_DECOMPRESSED_CAP - ENTRY_DECOMPRESSED_CAP,
ENTRY_DECOMPRESSED_CAP,
Ok(ARCHIVE_DECOMPRESSED_CAP)
)]
#[case::total_over_the_cap(
ARCHIVE_DECOMPRESSED_CAP - ENTRY_DECOMPRESSED_CAP + 1,
ENTRY_DECOMPRESSED_CAP,
Err("limit for one archive")
)]
#[case::size_cannot_overflow_the_total(u64::MAX, 1, Err("limit for one archive"))]
fn decompressed_caps_are_charged_per_entry_and_per_archive(
#[case] declared: u64,
#[case] size: u64,
#[case] expected: Result<u64, &str>,
) {
let archive = camino::Utf8Path::new("/tmp/fixture.tar.gz");
let relative = camino::Utf8Path::new("lib/fixture.so");
match (
check_decompressed_caps(archive, relative, size, declared),
expected,
) {
(Ok(total), Ok(want)) => assert_eq!(total, want, "running total"),
(Err(err), Err(needle)) => {
assert_eq!(err.kind(), BootstrapErrorKind::ExtensionArchiveInvalid);
assert!(err.to_string().contains(needle), "{err}");
}
(got, want) => panic!("expected {want:?}, got {got:?}"),
}
}
#[test]
fn install_refuses_an_oversized_swapped_archive() {
let prepared = prepared(&fixture_entries()).expect("fixture");
let mut oversized = std::fs::read(&prepared.archive).expect("read");
oversized.extend(std::iter::repeat_n(0_u8, 4096));
std::fs::write(&prepared.archive, &oversized).expect("swap");
let err = install(&prepared).expect_err("rejected");
assert_eq!(
err.kind(),
BootstrapErrorKind::ExtensionArchiveDigestMismatch
);
}
#[cfg(unix)]
#[test]
fn a_symlinked_installation_root_is_refused() {
let prepared = prepared(&fixture_entries()).expect("fixture");
let escape = prepared
.install_dir
.parent()
.expect("install tree has a parent")
.join("escape");
std::fs::create_dir(&escape).expect("escape directory");
std::fs::remove_dir_all(&prepared.install_dir).expect("clear the install tree");
std::os::unix::fs::symlink(escape.as_std_path(), prepared.install_dir.as_std_path())
.expect("symlink the install tree");
let err = install(&prepared).expect_err("a symlinked installation root must be refused");
assert_eq!(
err.kind(),
BootstrapErrorKind::ExtensionInstallFailed,
"{err}"
);
assert!(
std::fs::read_dir(&escape)
.expect("read escape")
.next()
.is_none(),
"the escape directory must stay empty"
);
}
#[cfg(unix)]
#[test]
fn an_identical_file_behind_a_symlinked_destination_is_not_followed() {
use std::os::unix::fs::PermissionsExt;
use super::fixture::FIXTURE_FILES;
let prepared = prepared(&fixture_entries()).expect("fixture");
let body = FIXTURE_FILES
.iter()
.find(|(name, _)| *name == "lib/fixture.so")
.map(|(_, body)| *body)
.expect("the fixture carries a shared object");
let outside = prepared
.install_dir
.parent()
.expect("install tree has a parent")
.join("outside.so");
std::fs::write(&outside, body).expect("write the file outside the tree");
std::fs::set_permissions(&outside, std::fs::Permissions::from_mode(0o600)).expect("chmod");
let destination = prepared.install_dir.join("lib/fixture.so");
std::os::unix::fs::symlink(outside.as_std_path(), destination.as_std_path())
.expect("symlink the destination");
install(&prepared).expect("install over a symlinked destination");
assert_eq!(
std::fs::symlink_metadata(&outside)
.expect("outside metadata")
.permissions()
.mode()
& 0o777,
0o600,
"the file outside the tree must not be chmodded through the link"
);
assert!(
!std::fs::symlink_metadata(&destination)
.expect("destination metadata")
.file_type()
.is_symlink(),
"the symlink must be replaced by a real file"
);
}
#[cfg(unix)]
#[test]
fn a_parent_swapped_after_the_tree_is_opened_cannot_redirect_the_write() {
use camino::Utf8Path;
use crate::extensions::tree::InstallTree;
let prepared = prepared(&fixture_entries()).expect("fixture");
let root = prepared.install_dir.clone();
let escape = root
.parent()
.expect("install tree has a parent")
.join("escape");
std::fs::create_dir_all(escape.join("lib")).expect("escape directory");
let tree = InstallTree::open(&root).expect("open the installation tree");
let moved = root.with_file_name("moved-aside");
std::fs::rename(root.as_std_path(), moved.as_std_path()).expect("move the tree aside");
std::os::unix::fs::symlink(escape.as_std_path(), root.as_std_path()).expect("symlink the root");
let relative = Utf8Path::new("lib/swapped.so");
tree.create_parents(relative).expect("create parents");
let (file, temp) = tree.create_temp_beside(relative).expect("temporary file");
std::io::Write::write_all(&mut (&file), b"payload").expect("write payload");
drop(file);
tree.place(&temp, relative).expect("place the file");
assert!(
moved.join(relative).exists(),
"the write must land in the directory the handle was opened on"
);
assert!(
!escape.join(relative).exists(),
"the write must not follow the symlink planted after the handle was opened"
);
}
#[cfg(unix)]
#[test]
fn a_validated_parent_swapped_inside_the_race_window_is_refused() {
use camino::Utf8Path;
use crate::extensions::tree::InstallTree;
let prepared = prepared(&fixture_entries()).expect("fixture");
let root = prepared.install_dir.clone();
let escape = root
.parent()
.expect("install tree has a parent")
.join("escape");
std::fs::create_dir_all(&escape).expect("escape directory");
let tree = InstallTree::open(&root).expect("open the installation tree");
let relative = Utf8Path::new("lib/swapped.so");
tree.require_real_parents(relative)
.expect("a real parent must validate");
let lib = root.join("lib");
std::fs::remove_dir_all(&lib).expect("clear lib");
std::os::unix::fs::symlink(escape.as_std_path(), lib.as_std_path()).expect("symlink lib");
let reported = match (
tree.create_parents(relative),
tree.create_temp_beside(relative),
) {
(Err(err), _) | (Ok(()), Err(err)) => format!("{err:#}"),
(Ok(()), Ok(_)) => {
panic!("creating under a parent swapped for an escaping symlink must be refused")
}
};
assert!(
reported.contains("lib"),
"the refusal must name the offending path, got: {reported}"
);
assert!(
!escape.join("swapped.so").exists(),
"nothing may be written through the swapped parent"
);
assert!(
std::fs::read_dir(&escape)
.expect("read escape")
.next()
.is_none(),
"the escape directory must stay empty"
);
}