use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use rucc_sysroot::{Manifest, Sysroot, sha256};
use rucc_tuple::TargetTuple;
use crate::{CliError, err};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Before {
Nothing,
TheSame,
Different(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Installed {
pub root: PathBuf,
pub digest: String,
pub files: usize,
pub before: Before,
}
pub fn verify(archive: &Path, expected: &str) -> Result<(), CliError> {
let bytes = fs::read(archive).map_err(|why| err(format!("{}: {why}", archive.display())))?;
let found = sha256::hex(&bytes);
if found == expected {
return Ok(());
}
Err(err(format!(
"{} has sha256 {found} where this release pins {expected}, so it is not the artifact this \
build knows about",
archive.display()
)))
}
pub fn install(
archive: &Path,
expected: &str,
target: TargetTuple,
cache: &Path,
) -> Result<Installed, CliError> {
verify(archive, expected)?;
let staging = staging_dir(cache, target);
fs::create_dir_all(&staging).map_err(|why| err(format!("{}: {why}", staging.display())))?;
let outcome = install_staged(archive, target, cache, &staging);
if outcome.is_err() {
let _ = fs::remove_dir_all(&staging);
}
outcome
}
fn install_staged(
archive: &Path,
target: TargetTuple,
cache: &Path,
staging: &Path,
) -> Result<Installed, CliError> {
unpack(archive, staging)?;
let record = staging.join("manifest");
let text = fs::read_to_string(&record).map_err(|why| {
if why.kind() == io::ErrorKind::NotFound {
err(format!(
"{} has no manifest in it, so there is nothing to check its files against",
archive.display()
))
} else {
err(format!("{}: {why}", record.display()))
}
})?;
let manifest =
Manifest::parse(&text).map_err(|why| err(format!("{}: {why}", archive.display())))?;
if manifest.target() != target {
return Err(err(format!(
"{} is a sysroot for {}, which is not {}",
archive.display(),
manifest.target().to_canonical_string(),
target.to_canonical_string()
)));
}
check(staging, &manifest).map_err(|why| err(format!("{}: {why}", archive.display())))?;
let digest = manifest.digest();
let root = Sysroot::in_cache(cache, target).root().to_path_buf();
let before = swap(staging, &root, &digest)?;
Ok(Installed { root, digest, files: manifest.inputs().len(), before })
}
fn staging_dir(cache: &Path, target: TargetTuple) -> PathBuf {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
let unique =
format!("{}-{}-{}", target.to_canonical_string(), std::process::id(), now.as_nanos());
cache.join("staging").join(unique)
}
fn unpack(archive: &Path, into: &Path) -> Result<(), CliError> {
let output =
Command::new("tar").arg("-xzf").arg(archive).arg("-C").arg(into).output().map_err(
|why| err(format!("could not run `tar`, which is how an artifact is unpacked: {why}")),
)?;
if output.status.success() {
return Ok(());
}
let said = String::from_utf8_lossy(&output.stderr);
let said = said.trim();
let detail = if said.is_empty() { String::new() } else { format!(": {said}") };
Err(err(format!("`tar` could not unpack {}{detail}", archive.display())))
}
fn check(tree: &Path, manifest: &Manifest) -> Result<(), String> {
let mut problems: Vec<String> = Vec::new();
let mut recorded: Vec<&str> = Vec::new();
for input in manifest.inputs() {
recorded.push(&input.path);
let at = match relative(tree, &input.path) {
Ok(at) => at,
Err(why) => {
problems.push(why);
continue;
}
};
match fs::read(&at) {
Ok(bytes) => {
let found = sha256::hex(&bytes);
if found != input.sha256 {
problems.push(format!(
"{} has sha256 {found} where the record says {}",
input.path, input.sha256
));
}
}
Err(why) if why.kind() == io::ErrorKind::NotFound => {
problems.push(format!("{} is in the record and not in the archive", input.path));
}
Err(why) => problems.push(format!("{}: {why}", input.path)),
}
}
let mut found = Vec::new();
walk(tree, String::new(), &mut found).map_err(|why| format!("{}: {why}", tree.display()))?;
recorded.sort_unstable();
for path in &found {
if path == "manifest" {
continue;
}
if recorded.binary_search(&path.as_str()).is_err() {
problems.push(format!("{path} is in the archive and not in the record"));
}
}
if problems.is_empty() {
return Ok(());
}
problems.sort();
let first = &problems[0];
if problems.len() == 1 {
return Err(format!("the archive does not match its own manifest: {first}"));
}
Err(format!(
"the archive does not match its own manifest: {first}, and {} more files disagree",
problems.len() - 1
))
}
fn relative(tree: &Path, path: &str) -> Result<PathBuf, String> {
let candidate = Path::new(path);
let ordinary = candidate.components().all(|part| matches!(part, Component::Normal(_)));
if !ordinary {
return Err(format!("{path} is not a path inside a sysroot"));
}
Ok(tree.join(candidate))
}
fn walk(dir: &Path, prefix: String, out: &mut Vec<String>) -> io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
let path = if prefix.is_empty() { name } else { format!("{prefix}/{name}") };
if entry.file_type()?.is_dir() {
walk(&entry.path(), path, out)?;
} else {
out.push(path);
}
}
Ok(())
}
fn swap(staging: &Path, root: &Path, digest: &str) -> Result<Before, CliError> {
let before = match existing(root) {
Some(found) if found == digest => {
let _ = fs::remove_dir_all(staging);
return Ok(Before::TheSame);
}
Some(found) => Before::Different(found),
None => Before::Nothing,
};
if let Some(parent) = root.parent() {
fs::create_dir_all(parent).map_err(|why| err(format!("{}: {why}", parent.display())))?;
}
let aside = root.with_extension(format!("old.{}", std::process::id()));
if before != Before::Nothing {
let _ = fs::remove_dir_all(&aside);
fs::rename(root, &aside)
.map_err(|why| err(format!("could not move {} aside: {why}", root.display())))?;
}
let renamed = fs::rename(staging, root);
if let Err(why) = renamed {
if before != Before::Nothing {
let _ = fs::rename(&aside, root);
}
return Err(err(format!("could not put {} in place: {why}", root.display())));
}
if before != Before::Nothing {
let _ = fs::remove_dir_all(&aside);
}
Ok(before)
}
fn existing(root: &Path) -> Option<String> {
let text = fs::read_to_string(root.join("manifest")).ok()?;
Manifest::parse(&text).ok().map(|manifest| manifest.digest())
}
#[cfg(test)]
mod tests {
use super::{Before, Installed, check, install, relative, verify, walk};
use rucc_sysroot::{Input, Licence, Manifest, Provenance, sha256};
use rucc_tuple::TargetTuple;
use std::path::{Path, PathBuf};
use std::process::Command;
struct Tree(PathBuf);
impl Drop for Tree {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
impl Tree {
fn new(name: &str) -> Tree {
let dir =
std::env::temp_dir().join(format!("rucc-install-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a temporary directory should be writable");
Tree(dir)
}
fn write(&self, path: &str, text: &str) {
let at = self.0.join(path);
if let Some(parent) = at.parent() {
std::fs::create_dir_all(parent).expect("a subdirectory should be creatable");
}
std::fs::write(&at, text).expect("a temporary file should be writable");
}
}
fn target() -> TargetTuple {
"x86_64-linux-musl".parse().expect("a tuple the table knows")
}
fn manifest_for(files: &[(&str, &str)]) -> Manifest {
let mut manifest = Manifest::new(target());
for (path, text) in files {
manifest.push(Input {
path: (*path).to_owned(),
source: "musl-1.2.5".to_owned(),
url: "https://musl.libc.org/releases/musl-1.2.5.tar.gz".to_owned(),
sha256: sha256::hex(text.as_bytes()),
licence: Licence::Mit,
provenance: Provenance::Bundled,
});
}
manifest
}
fn artifact(tree: &Tree, files: &[(&str, &str)], manifest: &Manifest) -> (PathBuf, String) {
let staged = tree.0.join("staged");
std::fs::create_dir_all(&staged).expect("a staging directory should be creatable");
for (path, text) in files {
let at = staged.join(path);
if let Some(parent) = at.parent() {
std::fs::create_dir_all(parent).expect("a subdirectory should be creatable");
}
std::fs::write(&at, text).expect("a file should be writable");
}
std::fs::write(staged.join("manifest"), manifest.render())
.expect("the manifest should be writable");
let archive = tree.0.join("artifact.tar.gz");
let status = Command::new("tar")
.arg("-czf")
.arg(&archive)
.arg("-C")
.arg(&staged)
.arg(".")
.status()
.expect("tar should be on a machine that runs these tests");
assert!(status.success(), "tar should be able to write an archive");
std::fs::remove_dir_all(&staged).expect("the staged tree should be removable");
let bytes = std::fs::read(&archive).expect("the archive should be readable");
let hash = sha256::hex(&bytes);
(archive, hash)
}
const FILES: &[(&str, &str)] =
&[("include/stdio.h", "int puts(const char *);\n"), ("lib/libc.so", "not really\n")];
#[test]
fn an_artifact_that_matches_its_record_is_installed() {
let tree = Tree::new("good");
let manifest = manifest_for(FILES);
let (archive, hash) = artifact(&tree, FILES, &manifest);
let cache = tree.0.join("cache");
let done = install(&archive, &hash, target(), &cache).expect("this one should install");
assert_eq!(done.before, Before::Nothing);
assert_eq!(done.files, 2);
assert_eq!(done.digest, manifest.digest());
assert_eq!(done.root, cache.join("sysroots").join("x86_64-linux-musl"));
assert!(done.root.join("include/stdio.h").is_file());
assert_eq!(
std::fs::read_to_string(done.root.join("manifest")).expect("a manifest"),
manifest.render()
);
let left: Vec<PathBuf> = std::fs::read_dir(cache.join("staging"))
.expect("the staging directory")
.map(|entry| entry.expect("an entry").path())
.collect();
assert_eq!(left, Vec::<PathBuf>::new(), "a staging tree was left behind");
}
#[test]
fn a_fetch_of_an_artifact_that_is_already_on_the_machine_installs_it() {
let tree = Tree::new("fetch");
let manifest = manifest_for(FILES);
let (built, hash) = artifact(&tree, FILES, &manifest);
let cache = tree.0.join("cache");
let pinned = rucc_sysroot::Pinned {
tuple: "x86_64-linux-musl",
url: "https://example.invalid/rucc-sysroot-x86_64-linux-musl.tar.gz",
sha256: String::leak(hash),
};
let at = pinned.archive_in(&cache);
std::fs::create_dir_all(at.parent().expect("a parent")).expect("a downloads directory");
std::fs::copy(&built, &at).expect("the artifact should be placeable");
assert_eq!(crate::fetch_sysroot(&pinned, target(), &cache), 0);
let root = cache.join("sysroots").join("x86_64-linux-musl");
assert!(root.join("include/stdio.h").is_file());
assert_eq!(
std::fs::read_to_string(root.join("manifest")).expect("a manifest"),
manifest.render()
);
assert_eq!(crate::fetch_sysroot(&pinned, target(), &cache), 0);
}
#[test]
fn the_same_artifact_twice_does_not_move_anything() {
let tree = Tree::new("again");
let manifest = manifest_for(FILES);
let (archive, hash) = artifact(&tree, FILES, &manifest);
let cache = tree.0.join("cache");
let first = install(&archive, &hash, target(), &cache).expect("the first install");
let second = install(&archive, &hash, target(), &cache).expect("the second install");
assert_eq!(second.before, Before::TheSame);
assert_eq!(second.root, first.root);
assert_eq!(second.digest, first.digest);
}
#[test]
fn a_hash_that_does_not_match_is_refused_before_anything_is_unpacked() {
let tree = Tree::new("hash");
let manifest = manifest_for(FILES);
let (archive, _) = artifact(&tree, FILES, &manifest);
let cache = tree.0.join("cache");
let wrong = "0".repeat(64);
let why =
install(&archive, &wrong, target(), &cache).expect_err("this is not the artifact");
assert!(why.message.contains("where this release pins"), "{}", why.message);
assert!(!cache.exists(), "a refused artifact should not have reached the cache");
}
#[test]
fn an_artifact_for_another_target_is_refused() {
let tree = Tree::new("target");
let mut manifest = Manifest::new("aarch64-linux-musl".parse().expect("a tuple"));
for (path, text) in FILES {
manifest.push(Input {
path: (*path).to_owned(),
source: "musl-1.2.5".to_owned(),
url: "https://musl.libc.org/releases/musl-1.2.5.tar.gz".to_owned(),
sha256: sha256::hex(text.as_bytes()),
licence: Licence::Mit,
provenance: Provenance::Bundled,
});
}
let (archive, hash) = artifact(&tree, FILES, &manifest);
let cache = tree.0.join("cache");
let why = install(&archive, &hash, target(), &cache).expect_err("the wrong target");
assert!(why.message.contains("aarch64-linux-musl"), "{}", why.message);
assert!(why.message.contains("x86_64-linux-musl"), "{}", why.message);
assert!(!cache.join("sysroots").exists(), "nothing should have been installed");
}
#[test]
fn an_archive_with_no_record_in_it_is_refused() {
let tree = Tree::new("bare");
let staged = tree.0.join("staged");
std::fs::create_dir_all(&staged).expect("a directory");
std::fs::write(staged.join("include.h"), "int x;\n").expect("a file");
let archive = tree.0.join("bare.tar.gz");
let status = Command::new("tar")
.arg("-czf")
.arg(&archive)
.arg("-C")
.arg(&staged)
.arg(".")
.status()
.expect("tar should run");
assert!(status.success());
let hash = sha256::hex(&std::fs::read(&archive).expect("readable"));
let cache = tree.0.join("cache");
let why = install(&archive, &hash, target(), &cache).expect_err("no manifest");
assert!(why.message.contains("has no manifest in it"), "{}", why.message);
}
#[test]
fn a_file_the_record_does_not_name_is_refused() {
let tree = Tree::new("extra");
let manifest = manifest_for(FILES);
let mut with_extra: Vec<(&str, &str)> = FILES.to_vec();
with_extra.push(("lib/surprise.o", "nobody wrote this down\n"));
let (archive, hash) = artifact(&tree, &with_extra, &manifest);
let cache = tree.0.join("cache");
let why = install(&archive, &hash, target(), &cache).expect_err("an unrecorded file");
assert!(why.message.contains("lib/surprise.o"), "{}", why.message);
assert!(why.message.contains("not in the record"), "{}", why.message);
}
#[test]
fn a_file_whose_bytes_changed_is_refused_and_so_is_one_that_is_missing() {
let tree = Tree::new("bytes");
let manifest = manifest_for(&[("include/stdio.h", "what the record says\n")]);
let (archive, hash) = artifact(&tree, &[("include/stdio.h", "what is there\n")], &manifest);
let cache = tree.0.join("cache");
let why = install(&archive, &hash, target(), &cache).expect_err("changed bytes");
assert!(why.message.contains("include/stdio.h has sha256"), "{}", why.message);
assert!(why.message.contains("where the record says"), "{}", why.message);
let gone = Tree::new("gone");
let manifest = manifest_for(FILES);
let (archive, hash) = artifact(&gone, &FILES[..1], &manifest);
let cache = gone.0.join("cache");
let why = install(&archive, &hash, target(), &cache).expect_err("a missing file");
assert!(why.message.contains("lib/libc.so"), "{}", why.message);
assert!(why.message.contains("not in the archive"), "{}", why.message);
}
#[test]
fn more_than_one_disagreement_says_how_many() {
let tree = Tree::new("count");
let manifest = manifest_for(&[("a.h", "one\n"), ("b.h", "two\n"), ("c.h", "three\n")]);
let (archive, hash) = artifact(
&tree,
&[("a.h", "not one\n"), ("b.h", "not two\n"), ("c.h", "three\n")],
&manifest,
);
let cache = tree.0.join("cache");
let why = install(&archive, &hash, target(), &cache).expect_err("two files disagree");
assert!(why.message.contains("and 1 more files disagree"), "{}", why.message);
}
#[test]
fn an_install_over_a_different_sysroot_says_what_it_replaced() {
let tree = Tree::new("replace");
let cache = tree.0.join("cache");
let first = manifest_for(&[("include/stdio.h", "the old one\n")]);
let (archive, hash) = artifact(&tree, &[("include/stdio.h", "the old one\n")], &first);
let done = install(&archive, &hash, target(), &cache).expect("the first install");
let was = done.digest.clone();
let second = Tree::new("replace-second");
let manifest = manifest_for(&[("include/stdio.h", "the new one\n")]);
let (archive, hash) = artifact(&second, &[("include/stdio.h", "the new one\n")], &manifest);
let done = install(&archive, &hash, target(), &cache).expect("the second install");
assert_eq!(done.before, Before::Different(was));
assert_eq!(
std::fs::read_to_string(done.root.join("include/stdio.h")).expect("the new file"),
"the new one\n"
);
let kept: Vec<String> = std::fs::read_dir(cache.join("sysroots"))
.expect("the sysroots directory")
.map(|entry| entry.expect("an entry").file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(kept, vec!["x86_64-linux-musl".to_owned()]);
}
#[test]
fn a_record_that_names_a_path_outside_the_tree_is_refused() {
assert!(relative(Path::new("/cache/sysroots/t"), "include/stdio.h").is_ok());
for path in ["../outside.h", "/etc/passwd", "include/../../outside.h"] {
let why = relative(Path::new("/cache/sysroots/t"), path)
.expect_err("this is not a path inside a sysroot");
assert!(why.contains(path), "{why}");
}
}
#[test]
fn the_walk_names_files_the_way_a_manifest_does() {
let tree = Tree::new("walk");
tree.write("include/sys/types.h", "typedef int t;\n");
tree.write("manifest", "rucc sysroot manifest 3\n");
let mut found = Vec::new();
walk(&tree.0, String::new(), &mut found).expect("the walk should work");
found.sort();
assert_eq!(found, vec!["include/sys/types.h".to_owned(), "manifest".to_owned()]);
}
#[test]
fn verify_is_the_hash_of_the_file_and_says_both_when_it_is_not() {
let tree = Tree::new("verify");
tree.write("thing", "bytes\n");
let at = tree.0.join("thing");
let hash = sha256::hex(b"bytes\n");
assert!(verify(&at, &hash).is_ok());
let why = verify(&at, &"f".repeat(64)).expect_err("a mismatch");
assert!(why.message.contains(&hash), "{}", why.message);
assert!(why.message.contains(&"f".repeat(64)), "{}", why.message);
let why = verify(&tree.0.join("absent"), &hash).expect_err("nothing to hash");
assert!(!why.message.contains("where this release pins"), "{}", why.message);
}
#[test]
fn the_check_passes_a_tree_that_matches() {
let tree = Tree::new("check");
for (path, text) in FILES {
tree.write(path, text);
}
tree.write("manifest", "rucc sysroot manifest 3\n");
let manifest = manifest_for(FILES);
assert_eq!(check(&tree.0, &manifest), Ok(()));
std::fs::create_dir_all(tree.0.join("lib/empty")).expect("a directory");
assert_eq!(check(&tree.0, &manifest), Ok(()));
}
#[test]
fn installed_says_where_and_what() {
let made = Installed {
root: PathBuf::from("/cache/sysroots/x86_64-linux-musl"),
digest: "0".repeat(64),
files: 3,
before: Before::Nothing,
};
assert_eq!(made.files, 3);
assert_eq!(made.before, Before::Nothing);
}
}