use std::{
fs,
io::{self, Read},
path::Path,
};
use camino::{Utf8Path, Utf8PathBuf};
use color_eyre::eyre::{Report, eyre};
use flate2::read::GzDecoder;
use tar::{Archive, EntryType};
use super::{Sha256Hex, extension_error, layout::classify_entry_path, manifest::ManifestArtifact};
use crate::error::{BootstrapError, BootstrapErrorKind, BootstrapResult};
pub(super) const LIB_MODE: u32 = 0o755;
pub(super) const SHARE_MODE: u32 = 0o644;
pub(super) const ENTRY_DECOMPRESSED_CAP: u64 = 64 * 1024 * 1024;
pub(super) const ARCHIVE_DECOMPRESSED_CAP: u64 = 256 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct PlannedFile {
pub(super) relative: Utf8PathBuf,
pub(super) mode: u32,
}
pub(super) fn install_archive(
path: &Utf8Path,
artifact: &ManifestArtifact,
install_dir: &Utf8Path,
) -> BootstrapResult<Vec<Utf8PathBuf>> {
let bytes = read_verified(path, artifact)?;
let planned = plan(path, &bytes, artifact)?;
super::write::write_all(path, &bytes, &planned, install_dir)?;
tracing::info!(
target: super::LOG_TARGET,
archive = %path,
files = planned.len(),
install_dir = %install_dir,
"installed extension archive"
);
Ok(planned.into_iter().map(|file| file.relative).collect())
}
fn read_verified(path: &Utf8Path, artifact: &ManifestArtifact) -> BootstrapResult<Vec<u8>> {
let file = fs::File::open(path)
.map_err(|err| invalid(path, &format!("cannot open archive: {err}")))?;
let mut bytes = Vec::new();
file.take(artifact.size.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|err| invalid(path, &format!("cannot read archive: {err}")))?;
let actual = Sha256Hex::of_bytes(&bytes);
if bytes.len() as u64 != artifact.size || actual != artifact.sha256 {
return Err(extension_error(
BootstrapErrorKind::ExtensionArchiveDigestMismatch,
eyre!(
"extension archive {path} hashes to {actual} but the manifest records {}",
artifact.sha256
),
));
}
Ok(bytes)
}
fn plan(
path: &Utf8Path,
bytes: &[u8],
artifact: &ManifestArtifact,
) -> BootstrapResult<Vec<PlannedFile>> {
let mut files: Vec<PlannedFile> = Vec::new();
let mut seen: std::collections::BTreeSet<Utf8PathBuf> = std::collections::BTreeSet::new();
let mut declared: u64 = 0;
let mut reader = open_archive(bytes);
for entry_result in reader.entries()? {
let entry =
entry_result.map_err(|err| invalid(path, &format!("unreadable entry: {err}")))?;
let size = entry.header().size().map_err(|err| {
invalid(
path,
&format!(
"entry {:?} has an unreadable size: {err}",
entry.path_bytes_lossy()
),
)
})?;
if let Some(file) =
plan_entry(path, entry.header().entry_type(), &entry.path_bytes_lossy())?
{
if !seen.insert(file.relative.clone()) {
return Err(invalid(path, &format!("duplicate entry {}", file.relative)));
}
declared = check_decompressed_caps(path, &file.relative, size, declared)?;
files.push(file);
}
}
files.sort_by(|a, b| a.relative.as_str().cmp(b.relative.as_str()));
check_against_manifest(path, &files, artifact)?;
Ok(files)
}
fn plan_entry(
path: &Utf8Path,
kind: EntryType,
name: &str,
) -> BootstrapResult<Option<PlannedFile>> {
if kind.is_dir() {
return Ok(None);
}
if !kind.is_file() {
return Err(invalid(
path,
&format!(
"entry {name:?} is a {kind:?}; only regular files and directories are allowed"
),
));
}
let relative = classify_entry_path(Path::new(name)).ok_or_else(|| {
invalid(
path,
&format!("entry {name:?} is outside lib/ or share/extension/"),
)
})?;
let mode = if relative.starts_with("lib") {
LIB_MODE
} else {
SHARE_MODE
};
Ok(Some(PlannedFile { relative, mode }))
}
pub(super) fn check_decompressed_caps(
path: &Utf8Path,
relative: &Utf8Path,
size: u64,
declared: u64,
) -> BootstrapResult<u64> {
if size > ENTRY_DECOMPRESSED_CAP {
return Err(invalid(
path,
&format!(
"{relative} declares {size} decompressed bytes, above the \
{ENTRY_DECOMPRESSED_CAP}-byte per-file limit"
),
));
}
let total = declared.saturating_add(size);
if total > ARCHIVE_DECOMPRESSED_CAP {
return Err(invalid(
path,
&format!(
"the archive declares at least {total} decompressed bytes by {relative}, above \
the {ARCHIVE_DECOMPRESSED_CAP}-byte limit for one archive"
),
));
}
Ok(total)
}
fn check_against_manifest(
path: &Utf8Path,
files: &[PlannedFile],
artifact: &ManifestArtifact,
) -> BootstrapResult<()> {
let mut expected: Vec<&str> = artifact.files.iter().map(String::as_str).collect();
expected.sort_unstable();
let actual: Vec<&str> = files.iter().map(|file| file.relative.as_str()).collect();
if actual != expected {
return Err(invalid(
path,
&format!("archive contents {actual:?} differ from the manifest file list {expected:?}"),
));
}
Ok(())
}
pub(super) type Entries<'r, 'b> = tar::Entries<'r, GzDecoder<io::Cursor<&'b [u8]>>>;
pub(super) fn open_archive(bytes: &[u8]) -> OpenArchive<'_> {
OpenArchive {
archive: Archive::new(GzDecoder::new(io::Cursor::new(bytes))),
}
}
pub(super) struct OpenArchive<'a> {
archive: Archive<GzDecoder<io::Cursor<&'a [u8]>>>,
}
impl<'b> OpenArchive<'b> {
pub(super) fn entries(&mut self) -> BootstrapResult<Entries<'_, 'b>> {
self.archive.entries().map_err(|err| {
extension_error(
BootstrapErrorKind::ExtensionArchiveInvalid,
eyre!("cannot read archive entries: {err}"),
)
})
}
}
pub(super) trait EntryPathExt {
fn path_bytes_lossy(&self) -> String;
fn path_bytes_lossy_path(&self) -> std::path::PathBuf;
}
impl<R: Read> EntryPathExt for tar::Entry<'_, R> {
fn path_bytes_lossy(&self) -> String {
String::from_utf8_lossy(&self.path_bytes()).into_owned()
}
fn path_bytes_lossy_path(&self) -> std::path::PathBuf {
std::path::PathBuf::from(self.path_bytes_lossy())
}
}
pub(super) fn invalid(path: &Utf8Path, detail: &str) -> BootstrapError {
extension_error(
BootstrapErrorKind::ExtensionArchiveInvalid,
eyre!("extension archive {path}: {detail}"),
)
}
pub(super) fn install_failed(
relative: &Utf8Path,
written: &[Utf8PathBuf],
err: Report,
) -> BootstrapError {
extension_error(
BootstrapErrorKind::ExtensionInstallFailed,
err.wrap_err(format!(
"failed to install {relative}; files already written: {written:?}"
)),
)
}