Skip to main content

assay/install/
extract.rs

1//! Extract `.tar.gz` artifacts from the cache into the destination
2//! filesystem paths.
3//!
4//! Two install shapes:
5//! - **Extension binary** — pull one named member out of the tarball and
6//!   atomic-rename it into `<bin_dir>/<bin_name>` with mode 0755.
7//! - **Lib tree** — extract the whole tarball into `<lib_dir>/<lib_name>/`,
8//!   replacing any existing tree at that path.
9//!
10//! ### Tarball layout assumptions
11//!
12//! - Extensions: tarball contains the binary as a top-level entry whose
13//!   filename matches `bin_name` (e.g. an archive containing `assay-engine`).
14//! - Libs: tarball contains the lib's tree at the root (i.e. extracting the
15//!   archive into a directory yields the lib's contents directly, *not* a
16//!   `<name>/` wrapping directory).
17//!
18//! The release pipeline (plan 21 phase 5) finalises both conventions.
19
20use std::fs;
21use std::io::Read;
22use std::path::{Path, PathBuf};
23
24use flate2::read::GzDecoder;
25use tar::Archive;
26use thiserror::Error;
27
28#[derive(Debug, Error)]
29pub enum ExtractError {
30    #[error("{name}: I/O error: {source}")]
31    Io {
32        name: String,
33        #[source]
34        source: std::io::Error,
35    },
36
37    #[error("{name}: archive `{archive}` contains no top-level entry named `{member}`")]
38    BinaryMemberNotFound {
39        name: String,
40        archive: PathBuf,
41        member: String,
42    },
43}
44
45/// Install one extension binary from `archive_path` into
46/// `<bin_dir>/<bin_name>` (mode 0755 on unix), replacing any existing file.
47pub fn install_extension_binary(
48    archive_path: &Path,
49    bin_dir: &Path,
50    bin_name: &str,
51) -> Result<PathBuf, ExtractError> {
52    let name = bin_name.to_string();
53    fs::create_dir_all(bin_dir).map_err(io(&name))?;
54
55    let bytes = read_member(archive_path, bin_name).map_err(io(&name))?.ok_or_else(|| {
56        ExtractError::BinaryMemberNotFound {
57            name: name.clone(),
58            archive: archive_path.to_path_buf(),
59            member: bin_name.to_string(),
60        }
61    })?;
62
63    let final_path = bin_dir.join(bin_name);
64    let tmp_path = bin_dir.join(format!(".{bin_name}.tmp"));
65    // Best-effort cleanup of leftover tmp from a prior crashed install.
66    let _ = fs::remove_file(&tmp_path);
67
68    fs::write(&tmp_path, &bytes).map_err(io(&name))?;
69    set_executable_mode(&tmp_path).map_err(io(&name))?;
70    fs::rename(&tmp_path, &final_path).map_err(io(&name))?;
71
72    Ok(final_path)
73}
74
75/// Install a Lua library tree from `archive_path` into
76/// `<lib_dir>/<lib_name>/`, replacing any existing tree at that path.
77///
78/// Strategy: extract into a sibling staging directory `.<name>.new`,
79/// then swap (`rm -rf <name>` if present, `mv <name>.new <name>`). The
80/// swap window is small but not strictly atomic for non-empty target
81/// directories — POSIX `rename` only clobbers empty directories.
82pub fn install_lib_tree(
83    archive_path: &Path,
84    lib_dir: &Path,
85    lib_name: &str,
86) -> Result<PathBuf, ExtractError> {
87    let name = lib_name.to_string();
88    fs::create_dir_all(lib_dir).map_err(io(&name))?;
89
90    let target = lib_dir.join(lib_name);
91    let staging = lib_dir.join(format!(".{lib_name}.new"));
92
93    // Drop any leftover staging from a prior crashed install.
94    if staging.exists() {
95        fs::remove_dir_all(&staging).map_err(io(&name))?;
96    }
97    fs::create_dir_all(&staging).map_err(io(&name))?;
98
99    let f = fs::File::open(archive_path).map_err(io(&name))?;
100    let mut archive = Archive::new(GzDecoder::new(f));
101    archive.unpack(&staging).map_err(io(&name))?;
102
103    if target.exists() {
104        fs::remove_dir_all(&target).map_err(io(&name))?;
105    }
106    fs::rename(&staging, &target).map_err(io(&name))?;
107
108    Ok(target)
109}
110
111fn read_member(archive_path: &Path, member: &str) -> std::io::Result<Option<Vec<u8>>> {
112    let target_basename = Path::new(member);
113    let f = fs::File::open(archive_path)?;
114    let mut archive = Archive::new(GzDecoder::new(f));
115    for entry in archive.entries()? {
116        let mut entry = entry?;
117        let path = entry.path()?.into_owned();
118        if path.file_name() == Some(target_basename.as_os_str()) {
119            let mut buf = Vec::new();
120            entry.read_to_end(&mut buf)?;
121            return Ok(Some(buf));
122        }
123    }
124    Ok(None)
125}
126
127fn io(name: &str) -> impl Fn(std::io::Error) -> ExtractError + '_ {
128    let owned = name.to_string();
129    move |source| ExtractError::Io {
130        name: owned.clone(),
131        source,
132    }
133}
134
135#[cfg(unix)]
136fn set_executable_mode(path: &Path) -> std::io::Result<()> {
137    use std::os::unix::fs::PermissionsExt;
138    fs::set_permissions(path, fs::Permissions::from_mode(0o755))
139}
140
141#[cfg(not(unix))]
142fn set_executable_mode(_path: &Path) -> std::io::Result<()> {
143    Ok(())
144}