Skip to main content

eska_tool_runtime/
installation.rs

1//! Managed receipts reject unsupported schemas without hashing a large executable on every startup.
2
3use crate::{Error, contract};
4use serde_json::Value;
5use std::{fs, io::Read, path::Path};
6
7/// Check declared compatibility and executable size for a managed, canonically named component.
8///
9/// # Errors
10/// Returns an incompatible installation for a malformed receipt, unsupported contract or changed size.
11pub fn verify(binary: &Path, tool: &str) -> Result<(), Error> {
12    let name = format!("{tool}{}", std::env::consts::EXE_SUFFIX);
13    let Some(bin) = binary
14        .parent()
15        .filter(|path| path.file_name().is_some_and(|name| name == "bin"))
16    else {
17        return Ok(());
18    };
19    if binary.file_name() != Some(std::ffi::OsStr::new(&name)) {
20        return Ok(());
21    }
22    let Some(root) = bin.parent() else {
23        return Ok(());
24    };
25    let directory = root.join("components");
26    let receipt = directory.join(format!("{tool}.json"));
27    let invalid = || Error::Incompatible {
28        tool: tool.to_owned(),
29        receipt: receipt.clone(),
30    };
31    match fs::symlink_metadata(&receipt) {
32        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
33        Err(_) => return Err(invalid()),
34        Ok(info) if !info.is_file() || info.len() > 4 * 1024 * 1024 => return Err(invalid()),
35        Ok(_) => {}
36    }
37    if !fs::symlink_metadata(directory).is_ok_and(|info| info.is_dir()) {
38        return Err(invalid());
39    }
40    let mut data = Vec::new();
41    fs::File::open(&receipt)
42        .map_err(|_| invalid())?
43        .take(4 * 1024 * 1024 + 1)
44        .read_to_end(&mut data)
45        .map_err(|_| invalid())?;
46    if data.len() > 4 * 1024 * 1024 {
47        return Err(invalid());
48    }
49    let value: Value = serde_json::from_slice(&data).map_err(|_| invalid())?;
50    let package = &value["package"];
51    let metadata = &package["contract"];
52    let valid = value.as_object().is_some_and(|object| object.len() == 4)
53        && value["schema_version"] == 1
54        && value["method"] == "component-archive"
55        && sha256(&value["archive_sha256"])
56        && package.as_object().is_some_and(|object| object.len() == 6)
57        && package["schema_version"] == 1
58        && package["tool"] == tool
59        && package["version"] == metadata["version"]
60        && package["target"] == contract::TARGET
61        && contract::compatible(metadata, tool)
62        && package["files"].as_array().is_some_and(|files| {
63            let matches: Vec<_> = files
64                .iter()
65                .filter(|item| item["path"] == format!("bin/{name}"))
66                .collect();
67            (2..=8192).contains(&files.len())
68                && matches.len() == 1
69                && matches[0]["executable"] == true
70                && sha256(&matches[0]["sha256"])
71                && fs::metadata(binary)
72                    .is_ok_and(|info| matches[0]["size"].as_u64() == Some(info.len()))
73        });
74    if valid { Ok(()) } else { Err(invalid()) }
75}
76
77/// Digests remain canonical even though content ownership is checked by the installer before mutation.
78fn sha256(value: &Value) -> bool {
79    value.as_str().is_some_and(|text| {
80        text.len() == 64
81            && text
82                .bytes()
83                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
84    })
85}