#[cfg(test)]
mod tests;
use crate::output_with_executable_busy_retry;
use ic_host_fs::durable::{NamedWriteError, PublicationMode, WriteOptions, write_validated_with};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::{
env,
fs::{self, File, OpenOptions},
io,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
const TEMP_ATTEMPTS: usize = 64;
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
pub enum ArchiveFormat {
Gzip,
Xz,
}
pub struct InstallSpec<'a> {
pub tool: &'static str,
pub archive_name: &'a str,
pub archive_url: &'a str,
pub archive_sha256: &'static str,
pub member: &'a str,
pub runtime_library: Option<&'a str>,
pub format: ArchiveFormat,
}
#[derive(Debug, thiserror::Error)]
pub enum InstallError {
#[error("archive download failed ({status}): {stderr}")]
ArchiveDownload { status: String, stderr: String },
#[error("archive extraction failed ({status}): {stderr}")]
ArchiveExtraction { status: String, stderr: String },
#[error("archive hash mismatch at {path}: expected {expected}, observed {actual}")]
ArchiveHashMismatch {
path: PathBuf,
actual: String,
expected: &'static str,
},
#[error("executable hash mismatch at {path}: expected {expected}, observed {actual}")]
ExecutableHashMismatch {
path: PathBuf,
actual: String,
expected: String,
},
#[error("{operation} at {path}: {source}")]
Io {
operation: &'static str,
path: PathBuf,
source: io::Error,
},
#[error("temporary installation names exhausted beneath {root}")]
TempDirectoryExhausted { root: PathBuf },
}
pub fn install<T, E: From<InstallError> + std::error::Error + Send + Sync + 'static>(
spec: &InstallSpec<'_>,
destination: &Path,
admit: impl Fn(&Path) -> Result<T, E>,
) -> Result<T, E> {
let temp = TempDirectory::create(spec.tool)?;
let archive = temp.path.join(spec.archive_name);
download_archive(spec.archive_url, &archive)?;
verify_archive(&archive, spec.archive_sha256)?;
extract_archive(spec, &archive, &temp.path)?;
let candidate = temp.path.join(spec.member);
admit(&candidate)?;
if let Some(library) = spec.runtime_library {
publish_bundle(
spec.tool,
&candidate,
&temp.path.join(library),
destination,
|path| admit(path).map(|_| ()),
)?;
} else {
publish_executable(&candidate, destination, |path| admit(path).map(|_| ()))?;
}
admit(destination)
}
fn download_archive(url: &str, archive: &Path) -> Result<(), InstallError> {
let mut command = crate::build_environment::command("curl");
command
.args([
"--proto",
"=https",
"--proto-redir",
"=https",
"--tlsv1.2",
"-fsSL",
"-o",
])
.arg(archive)
.arg(url);
let output = output_with_executable_busy_retry(&mut command)
.map_err(|source| io_error("download tool archive", archive, source))?;
if !output.status.success() {
return Err(InstallError::ArchiveDownload {
status: output.status.to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
Ok(())
}
fn verify_archive(archive: &Path, expected: &'static str) -> Result<(), InstallError> {
let actual = sha256_file(archive)?;
if actual != expected {
return Err(InstallError::ArchiveHashMismatch {
path: archive.to_path_buf(),
actual,
expected,
});
}
Ok(())
}
fn extract_archive(
spec: &InstallSpec<'_>,
archive: &Path,
destination: &Path,
) -> Result<(), InstallError> {
let compression = match spec.format {
ArchiveFormat::Gzip => "-xzf",
ArchiveFormat::Xz => "-xJf",
};
let mut command = crate::build_environment::command("tar");
command
.arg(compression)
.arg(archive)
.arg("-C")
.arg(destination)
.arg(spec.member);
if let Some(library) = spec.runtime_library {
command.arg(library);
}
let output = output_with_executable_busy_retry(&mut command)
.map_err(|source| io_error("extract tool archive", archive, source))?;
if !output.status.success() {
return Err(InstallError::ArchiveExtraction {
status: output.status.to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
Ok(())
}
pub fn publish_executable<E: From<InstallError> + std::error::Error + Send + Sync + 'static>(
candidate: &Path,
destination: &Path,
admit: impl FnOnce(&Path) -> Result<(), E>,
) -> Result<(), E> {
let expected = sha256_file(candidate)?;
write_validated_with(
destination,
WriteOptions {
mode: PublicationMode::Replace,
permissions: 0o755,
},
|output| {
let mut source = File::open(candidate)
.map_err(|source| io_error("open admitted tool executable", candidate, source))?;
io::copy(&mut source, output)
.map_err(|source| io_error("write staged tool executable", destination, source))?;
#[cfg(unix)]
output
.set_permissions(fs::Permissions::from_mode(0o755))
.map_err(|source| {
io_error(
"set staged tool executable permissions",
destination,
source,
)
})?;
Ok(())
},
|stage, &()| {
let actual = sha256_file(stage)?;
if actual != expected {
return Err(InstallError::ExecutableHashMismatch {
path: stage.to_path_buf(),
actual,
expected,
}
.into());
}
admit(stage)
},
)
.map_err(|error| match error {
NamedWriteError::Producer {
source,
cleanup_error: None,
} => source,
error => {
let kind = match &error {
NamedWriteError::Producer { .. } => io::ErrorKind::Other,
NamedWriteError::BeforePublication { source, .. }
| NamedWriteError::AfterPublication { source } => source.kind(),
};
io_error(
"publish admitted tool executable",
destination,
io::Error::new(kind, error),
)
.into()
}
})
}
fn stage_executable(candidate: &Path, stage: &Path) -> Result<(), InstallError> {
let mut source = File::open(candidate)
.map_err(|source| io_error("open admitted tool executable", candidate, source))?;
let mut output = OpenOptions::new()
.create_new(true)
.write(true)
.open(stage)
.map_err(|source| io_error("create staged tool executable", stage, source))?;
io::copy(&mut source, &mut output)
.map_err(|source| io_error("write staged tool executable", stage, source))?;
#[cfg(unix)]
fs::set_permissions(stage, fs::Permissions::from_mode(0o755))
.map_err(|source| io_error("set staged tool executable permissions", stage, source))?;
output
.sync_all()
.map_err(|source| io_error("sync staged tool executable", stage, source))?;
Ok(())
}
#[cfg(unix)]
fn publish_bundle<E: From<InstallError>>(
tool: &str,
candidate: &Path,
library: &Path,
destination: &Path,
admit: impl Fn(&Path) -> Result<(), E>,
) -> Result<(), E> {
let parent = destination.parent().ok_or_else(|| {
io_error(
"select tool installation directory",
destination,
io::Error::new(io::ErrorKind::InvalidInput, "destination has no parent"),
)
})?;
fs::create_dir_all(parent)
.map_err(|source| io_error("create tool installation directory", parent, source))?;
let parent = fs::canonicalize(parent)
.map_err(|source| io_error("resolve tool installation directory", parent, source))?;
let bundle = parent.join(format!(
".canic-{tool}-bundle-{}-{}",
std::process::id(),
TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&bundle).map_err(|source| io_error("create tool bundle", &bundle, source))?;
let link = bundle.join("selected");
let mut published = false;
let result = (|| {
let bin = bundle.join("bin");
let lib = bundle.join("lib");
for directory in [&bin, &lib] {
fs::create_dir(directory)
.map_err(|source| io_error("create tool bundle directory", directory, source))?;
}
let executable = bin.join(tool);
let staged_library = lib.join("libbinaryen.dylib");
stage_bundle_member(candidate, &executable)?;
stage_bundle_member(library, &staged_library)?;
admit(&executable)?;
for directory in [&bin, &lib, &bundle] {
File::open(directory)
.and_then(|file| file.sync_all())
.map_err(|source| io_error("sync tool bundle directory", directory, source))?;
}
std::os::unix::fs::symlink(&executable, &link)
.map_err(|source| io_error("stage tool bundle selection", &link, source))?;
File::open(&bundle)
.and_then(|file| file.sync_all())
.map_err(|source| io_error("sync tool bundle selection", &bundle, source))?;
fs::rename(&link, destination)
.map_err(|source| io_error("publish tool bundle", destination, source))?;
published = true;
File::open(&parent)
.and_then(|file| file.sync_all())
.map_err(|source| {
E::from(io_error(
"sync tool installation directory",
&parent,
source,
))
})
})();
if !published {
let _ = fs::remove_dir_all(&bundle);
}
result
}
fn stage_bundle_member(source: &Path, stage: &Path) -> Result<(), InstallError> {
let metadata = fs::symlink_metadata(source)
.map_err(|error| io_error("inspect tool bundle member", source, error))?;
if !metadata.is_file() {
return Err(io_error(
"inspect tool bundle member",
source,
io::Error::new(
io::ErrorKind::InvalidData,
"bundle member must be a regular file",
),
));
}
let expected = sha256_file(source)?;
stage_executable(source, stage)?;
let actual = sha256_file(stage)?;
if actual != expected {
return Err(InstallError::ExecutableHashMismatch {
path: stage.to_path_buf(),
actual,
expected,
});
}
Ok(())
}
pub fn sha256_file(path: &Path) -> Result<String, InstallError> {
ic_host_fs::read::hash_file(path, u64::MAX)
.map(|identity| identity.sha256.to_string())
.map_err(|source| io_error("hash admitted tool file", path, source.into()))
}
fn io_error(operation: &'static str, path: &Path, source: io::Error) -> InstallError {
InstallError::Io {
operation,
path: path.to_path_buf(),
source,
}
}
struct TempDirectory {
path: PathBuf,
}
impl TempDirectory {
fn create(tool: &str) -> Result<Self, InstallError> {
let root = env::temp_dir();
for _ in 0..TEMP_ATTEMPTS {
let path = root.join(format!(
"canic-{tool}-install-{}-{}",
std::process::id(),
TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
match fs::create_dir(&path) {
Ok(()) => return Ok(Self { path }),
Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
Err(source) => {
return Err(io_error(
"create temporary tool installation directory",
&path,
source,
));
}
}
}
Err(InstallError::TempDirectoryExhausted { root })
}
}
impl Drop for TempDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}