From 8b6793a14722609c9328a9a58b31454941be225d Mon Sep 17 00:00:00 2001 From: Shanu Date: Thu, 3 Sep 2026 18:56:57 +0530 Subject: [PATCH 1/2] Cache verified release artifacts and bound the loader's network waits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every load of a pinned release downloaded it again: `acquire` extracted into a temporary directory that lived only as long as the process, so a host paid one archive per module per launch and left the extraction behind whenever it exited any way but a clean drop. The HTTP client had no timeouts either, so a TCP connect to an address that drops SYNs waited for the operating system to give up — 75 seconds on macOS — before the next address was tried. GitHub's asset CDN publishes several addresses in a fixed resolver order, and one unreachable address cost every download that full wait. Add `ModuleHost::load_github_release_cached`: the same two-sided verification into a directory the host names and that outlives the process. The archive, its extraction and the manifest digest stay there; a later launch re-hashes the archive against the host's pin, checks the extracted library against the release's own `modules.toml`, and maps it without the network. Downloads are staged beside the directory and committed with one rename, so a cache is either complete or absent, and a directory that fails verification is a miss rather than a refusal. The pure cache logic lives in `module/cache.rs` with its own tests; the network path stays in `github.rs`. Give the client resolve, connect, request and response budgets so a dead address costs a share of ten seconds, stream assets to disk instead of holding up to 512 MiB in memory, and build asset URLs from the tag rather than looking them up through the unauthenticated REST API, whose sixty requests per hour are shared by everyone behind one address. The API remains the fallback for a release whose direct path answers 404. --- crates/tinybus/src/module/cache.rs | 569 ++++++++++++++++++++++++++++ crates/tinybus/src/module/github.rs | 425 +++++++++++++++++---- crates/tinybus/src/module/host.rs | 38 +- crates/tinybus/src/module/mod.rs | 4 + docs/modules/module/README.md | 20 + 5 files changed, 974 insertions(+), 82 deletions(-) create mode 100644 crates/tinybus/src/module/cache.rs diff --git a/crates/tinybus/src/module/cache.rs b/crates/tinybus/src/module/cache.rs new file mode 100644 index 0000000..895cc76 --- /dev/null +++ b/crates/tinybus/src/module/cache.rs @@ -0,0 +1,569 @@ +//! Persistent, verified storage for release artifacts. Feature `modules`. +//! +//! [`super::github::acquire`] downloads a release into a temporary directory +//! that lives exactly as long as the process. That was the whole design once: +//! the bytes were verified moments ago, the host keeps the `TempDir` alive +//! because a mapped library may resolve sibling files, and nothing was written +//! anywhere an operator would find it. The cost showed up in the field. Every +//! launch of a host paid every download again, one archive per module, over a +//! network it does not control; and because a temporary directory is only +//! cleaned when its owner drops it, a process that exited any other way left the +//! extraction behind — gigabytes of them on a developer machine after a few days. +//! +//! This module is the on-disk half of the fix: a directory the host names, +//! holding the archive it verified, the extraction, and the digest the release +//! manifest published for the archive. The next launch finds it, re-hashes the +//! archive against the digest the host compiled in, and loads without a network +//! round-trip. What it does *not* do is decide policy: the host chooses the +//! directory, whether a download may happen at all, and when an old version is +//! removed. +//! +//! # Layout +//! +//! ```text +//! / the archive, kept so the next launch can re-verify it +//! /.sha256 the digest the release manifest published for it +//! /lib_module.so the extracted module — exactly one per directory +//! /modules.toml the module's own allowlist, when the release ships one +//! ``` +//! +//! # Why the archive stays on disk +//! +//! The digest a host compiles in names the *archive*, not the library inside +//! it. Keeping the archive is what lets a later launch check the same thing the +//! first launch checked, against the same pin, instead of trusting that the +//! extraction is still what it was. The extracted library is additionally held +//! to the release's own `modules.toml` — here before the directory is accepted, +//! and again by the allowlist gate every load goes through — so a corrupted +//! extraction beside an intact archive is a cache miss, not a permanent refusal. +//! +//! # Why a download is staged beside the directory +//! +//! A half-written cache must never be mistaken for a whole one. Everything is +//! downloaded and extracted into a sibling staging directory and moved into +//! place with one rename on the same filesystem, so the directory either holds +//! the complete verified set or does not exist. Two hosts racing to fill the +//! same directory are safe: the loser's rename fails, and it re-reads the +//! winner's files. + +use std::path::{Path, PathBuf}; + +use tracing::{debug, warn}; + +use crate::error::{Error, Result}; +use crate::module::hash::file_hex; + +/// Suffix of the marker beside an archive holding the digest its release +/// manifest published. +const DIGEST_MARKER_SUFFIX: &str = ".sha256"; + +/// Prefix of the sibling directory a download is assembled in before it is +/// committed. Dot-prefixed so a directory listing reads as "in progress". +const STAGING_PREFIX: &str = ".staging-"; + +/// A release found intact in a cache directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CachedArtifact { + /// Canonical path of the single platform module in the directory. + pub module: PathBuf, + /// Lowercase hex SHA-256 of the archive, as re-verified on this lookup. + pub sha256: String, +} + +/// The file extension a platform module carries on this target. +pub(crate) fn library_extension() -> &'static str { + if cfg!(windows) { + "dll" + } else if cfg!(target_os = "macos") { + "dylib" + } else { + "so" + } +} + +/// Look for `asset_name` in `dir` and prove it before answering. +/// +/// `Some` only when the archive is present, hashes to `expected_sha256` — or, +/// when the host pinned nothing, to the digest recorded beside it — and the +/// directory holds exactly one platform module whose own allowlist, if the +/// release shipped one, still matches. Anything less is `None`: a cache that +/// cannot be proven is treated as absent rather than as broken, so the caller +/// falls through to a fresh download instead of failing a launch over a stale or +/// half-written directory. Each reason is logged, because a cache that misses on +/// every launch is a bug worth seeing. +pub(crate) fn find_verified( + dir: &Path, + asset_name: &str, + expected_sha256: Option<&str>, +) -> Option { + let archive = dir.join(asset_name); + if !archive.is_file() { + debug!(asset = asset_name, "release cache: no archive"); + return None; + } + let expected = match expected_sha256 { + Some(pin) => pin.to_ascii_lowercase(), + None => read_digest_marker(dir, asset_name)?, + }; + if !crate::attest::is_hex_sha256(&expected) { + warn!( + asset = asset_name, + "release cache: expected digest is not a SHA-256" + ); + return None; + } + let Ok(file) = std::fs::File::open(&archive) else { + warn!(asset = asset_name, "release cache: archive is unreadable"); + return None; + }; + let Ok(actual) = file_hex(file) else { + warn!( + asset = asset_name, + "release cache: archive could not be hashed" + ); + return None; + }; + if actual != expected { + warn!( + asset = asset_name, + "release cache: archive digest does not match the pin; will download again" + ); + return None; + } + let module = match find_module(dir) { + Ok(module) => module, + Err(error) => { + warn!(asset = asset_name, error = %error, "release cache: extraction is unusable"); + return None; + } + }; + if !sidecar_matches(&module) { + warn!( + asset = asset_name, + "release cache: module does not match its allowlist; will download again" + ); + return None; + } + let Ok(module) = std::fs::canonicalize(&module) else { + warn!( + asset = asset_name, + "release cache: module path could not be canonicalized" + ); + return None; + }; + debug!(asset = asset_name, "release cache: hit"); + Some(CachedArtifact { + module, + sha256: expected, + }) +} + +/// Whether `module` agrees with the `modules.toml` beside it. +/// +/// No allowlist is not a disagreement: a release that ships none is verified +/// by its archive digest alone, exactly as on the first download. An allowlist +/// that does not name the module, cannot be read, or names a different hash is +/// a disagreement — the load gate would refuse the file, so the cache must not +/// claim it. +fn sidecar_matches(module: &Path) -> bool { + let Some(directory) = module.parent() else { + return false; + }; + let allowlist = directory.join("modules.toml"); + if !allowlist.exists() { + return true; + } + let Ok(source) = std::fs::read_to_string(&allowlist) else { + return false; + }; + let file_name = module + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + let file_stem = module + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(""); + let Some(expected) = crate::attest::parse_allowlist(&source) + .find(|(key, _)| key == file_name || key == file_stem) + .map(|(_, value)| value) + else { + return false; + }; + if !crate::attest::is_hex_sha256(&expected) { + return false; + } + let Ok(file) = std::fs::File::open(module) else { + return false; + }; + file_hex(file).is_ok_and(|actual| actual == expected) +} + +/// Where the digest marker for `asset_name` lives in `dir`. +pub(crate) fn digest_marker_path(dir: &Path, asset_name: &str) -> PathBuf { + dir.join(format!("{asset_name}{DIGEST_MARKER_SUFFIX}")) +} + +/// The digest recorded beside the archive, when there is a well-formed one. +fn read_digest_marker(dir: &Path, asset_name: &str) -> Option { + let recorded = std::fs::read_to_string(digest_marker_path(dir, asset_name)).ok()?; + let recorded = recorded.trim().to_ascii_lowercase(); + if crate::attest::is_hex_sha256(&recorded) { + Some(recorded) + } else { + warn!( + asset = asset_name, + "release cache: digest marker is malformed" + ); + None + } +} + +/// Record the digest the release manifest published for `asset_name`. +/// +/// # Errors +/// +/// Returns an error if the marker cannot be written. +pub(crate) fn write_digest_marker(dir: &Path, asset_name: &str, sha256: &str) -> Result<()> { + std::fs::write( + digest_marker_path(dir, asset_name), + format!("{}\n", sha256.to_ascii_lowercase()), + ) + .map_err(|_| refused(dir, "release digest marker could not be written")) +} + +/// The single platform module under `root`, searched recursively. +/// +/// # Errors +/// +/// Returns an error if `root` cannot be walked, holds no module, or holds more +/// than one — a release archive names exactly one library, and anything else is +/// a packaging fault rather than a choice for this crate to make. +pub(crate) fn find_module(root: &Path) -> Result { + let mut found = Vec::new(); + collect_modules(root, &mut found) + .map_err(|_| refused(root, "release archive could not be inspected"))?; + match found.as_slice() { + [module] => Ok(module.clone()), + [] => Err(refused(root, "release archive contains no platform module")), + _ => Err(refused( + root, + "release archive contains multiple platform modules", + )), + } +} + +fn collect_modules(path: &Path, found: &mut Vec) -> std::io::Result<()> { + for entry in std::fs::read_dir(path)? { + let path = entry?.path(); + if path.is_dir() { + collect_modules(&path, found)?; + } else if path.extension().and_then(|value| value.to_str()) == Some(library_extension()) { + found.push(path); + } + } + Ok(()) +} + +/// A fresh staging directory beside `dir`. +/// +/// Beside, not inside: the commit is a rename, and a rename is only atomic +/// within one filesystem. The parent is created if it is missing, since the +/// first launch on a machine has no cache tree at all. +/// +/// # Errors +/// +/// Returns an error if the parent cannot be created or the staging directory +/// cannot be made. +pub(crate) fn stage(dir: &Path) -> Result { + let parent = dir + .parent() + .ok_or_else(|| refused(dir, "release cache directory has no parent"))?; + std::fs::create_dir_all(parent) + .map_err(|_| refused(dir, "release cache directory could not be created"))?; + tempfile::Builder::new() + .prefix(STAGING_PREFIX) + .tempdir_in(parent) + .map_err(|_| refused(dir, "release staging directory could not be created")) +} + +/// Move a fully assembled staging directory into place as `dir`. +/// +/// Whatever was at `dir` — a previous extraction of the same version that failed +/// verification, or debris — is removed first, then the staging directory is +/// renamed over it. On failure the staging directory is deleted, so a failed +/// commit leaves nothing behind; the caller decides whether a sibling's +/// concurrent fill is acceptable by looking at `dir` again. +/// +/// # Errors +/// +/// Returns an error if the old directory cannot be removed or the rename fails. +pub(crate) fn commit(staging: tempfile::TempDir, dir: &Path) -> Result<()> { + // From here on this function owns cleanup: `keep` stops `TempDir` from + // deleting the directory on drop, which a successful rename would otherwise + // race against. + let staged = staging.keep(); + let outcome = (|| { + match std::fs::remove_dir_all(dir) { + Ok(()) => {} + // Nothing there to replace — including a target whose parent is + // not a directory, which the rename below reports on its own. + Err(_) if !dir.exists() => {} + Err(_) => { + return Err(refused( + dir, + "previous release directory could not be replaced", + )); + } + } + std::fs::rename(&staged, dir) + .map_err(|_| refused(dir, "verified release could not be moved into place")) + })(); + if outcome.is_err() { + let _ = std::fs::remove_dir_all(&staged); + } + outcome +} + +/// The fixed download URL of one asset of a GitHub release. +/// +/// Built rather than looked up: the path is a documented contract, and reaching +/// it through the REST API costs an unauthenticated request against a budget +/// shared by everyone behind one address. +pub(crate) fn release_download_url(owner: &str, repo: &str, tag: &str, asset_name: &str) -> String { + format!("https://github.com/{owner}/{repo}/releases/download/{tag}/{asset_name}") +} + +fn refused(path: &Path, reason: &'static str) -> Error { + Error::module_refused(path, reason) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ASSET: &str = "demo-module-1.0.0.tar.gz"; + + fn write(path: &Path, bytes: &[u8]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, bytes).unwrap(); + } + + fn module_name() -> String { + format!("libdemo_module.{}", library_extension()) + } + + /// A directory holding a verified-looking archive and one module. + fn populated(root: &Path) -> (PathBuf, String) { + let dir = root.join("demo").join("1.0.0"); + write(&dir.join(ASSET), b"archive bytes"); + write(&dir.join(module_name()), b"library bytes"); + let sha = crate::module::sha256_file(dir.join(ASSET)).unwrap(); + (dir, sha) + } + + #[test] + fn a_missing_archive_is_not_a_hit() { + let root = tempfile::tempdir().unwrap(); + assert_eq!( + find_verified(root.path(), ASSET, Some(&"a".repeat(64))), + None + ); + } + + #[test] + fn an_archive_matching_the_pin_beside_one_module_is_a_hit() { + let root = tempfile::tempdir().unwrap(); + let (dir, sha) = populated(root.path()); + let hit = find_verified(&dir, ASSET, Some(&sha.to_ascii_uppercase())).expect("hit"); + assert_eq!(hit.sha256, sha); + assert_eq!( + hit.module, + std::fs::canonicalize(dir.join(module_name())).unwrap() + ); + } + + #[test] + fn an_archive_that_does_not_match_the_pin_is_not_a_hit() { + let root = tempfile::tempdir().unwrap(); + let (dir, _) = populated(root.path()); + assert_eq!(find_verified(&dir, ASSET, Some(&"b".repeat(64))), None); + } + + #[test] + fn a_pin_that_is_not_a_digest_is_not_a_hit() { + let root = tempfile::tempdir().unwrap(); + let (dir, _) = populated(root.path()); + assert_eq!(find_verified(&dir, ASSET, Some("not-a-digest")), None); + } + + #[test] + fn without_a_pin_the_recorded_digest_decides() { + let root = tempfile::tempdir().unwrap(); + let (dir, sha) = populated(root.path()); + assert_eq!( + find_verified(&dir, ASSET, None), + None, + "nothing recorded yet" + ); + + write_digest_marker(&dir, ASSET, &sha.to_ascii_uppercase()).unwrap(); + let hit = find_verified(&dir, ASSET, None).expect("hit from the marker"); + assert_eq!(hit.sha256, sha); + + write(&digest_marker_path(&dir, ASSET), b"garbage\n"); + assert_eq!( + find_verified(&dir, ASSET, None), + None, + "a malformed marker is ignored" + ); + + write_digest_marker(&dir, ASSET, &"c".repeat(64)).unwrap(); + assert_eq!( + find_verified(&dir, ASSET, None), + None, + "a marker that disagrees with the archive is not trusted" + ); + } + + #[test] + fn a_directory_without_exactly_one_module_is_not_a_hit() { + let root = tempfile::tempdir().unwrap(); + let (dir, sha) = populated(root.path()); + + write(&dir.join("nested").join(module_name()), b"second"); + assert_eq!(find_verified(&dir, ASSET, Some(&sha)), None, "two modules"); + + std::fs::remove_dir_all(dir.join("nested")).unwrap(); + std::fs::remove_file(dir.join(module_name())).unwrap(); + assert_eq!(find_verified(&dir, ASSET, Some(&sha)), None, "no module"); + } + + #[test] + fn a_nested_module_is_found() { + let root = tempfile::tempdir().unwrap(); + let (dir, sha) = populated(root.path()); + let nested = dir.join("lib").join(module_name()); + std::fs::create_dir_all(nested.parent().unwrap()).unwrap(); + std::fs::rename(dir.join(module_name()), &nested).unwrap(); + let hit = find_verified(&dir, ASSET, Some(&sha)).expect("hit"); + assert_eq!(hit.module, std::fs::canonicalize(nested).unwrap()); + } + + #[test] + fn an_allowlist_beside_the_module_is_honoured() { + let root = tempfile::tempdir().unwrap(); + let (dir, sha) = populated(root.path()); + let module_sha = crate::module::sha256_file(dir.join(module_name())).unwrap(); + + write( + &dir.join("modules.toml"), + format!("\"{}\" = \"{module_sha}\"\n", module_name()).as_bytes(), + ); + assert!( + find_verified(&dir, ASSET, Some(&sha)).is_some(), + "agreeing allowlist" + ); + + write( + &dir.join("modules.toml"), + format!("\"{}\" = \"{}\"\n", module_name(), "d".repeat(64)).as_bytes(), + ); + assert_eq!( + find_verified(&dir, ASSET, Some(&sha)), + None, + "disagreeing allowlist" + ); + + write(&dir.join("modules.toml"), b"\"other.so\" = \"ee\"\n"); + assert_eq!( + find_verified(&dir, ASSET, Some(&sha)), + None, + "module absent from allowlist" + ); + + write( + &dir.join("modules.toml"), + format!("\"{}\" = \"not-hex\"\n", module_name()).as_bytes(), + ); + assert_eq!( + find_verified(&dir, ASSET, Some(&sha)), + None, + "invalid allowlist hash" + ); + } + + #[test] + fn staging_is_committed_by_one_rename_and_replaces_what_was_there() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("demo").join("1.0.0"); + write(&dir.join("stale"), b"old"); + + let staging = stage(&dir).unwrap(); + assert_eq!( + staging.path().parent().unwrap(), + dir.parent().unwrap(), + "staging is a sibling of the target" + ); + write(&staging.path().join(ASSET), b"new archive"); + let staged_path = staging.path().to_path_buf(); + + commit(staging, &dir).unwrap(); + assert!(!staged_path.exists(), "the staging directory moved"); + assert!(dir.join(ASSET).is_file(), "the new content is in place"); + assert!(!dir.join("stale").exists(), "the old content is gone"); + } + + #[test] + fn a_commit_onto_a_missing_target_creates_it() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("fresh").join("2.0.0"); + let staging = stage(&dir).unwrap(); + write(&staging.path().join(ASSET), b"bytes"); + commit(staging, &dir).unwrap(); + assert!(dir.join(ASSET).is_file()); + } + + #[test] + fn a_failed_commit_leaves_no_staging_directory_behind() { + let root = tempfile::tempdir().unwrap(); + let good = root.path().join("demo").join("1.0.0"); + let staging = stage(&good).unwrap(); + let staged_path = staging.path().to_path_buf(); + write(&staged_path.join(ASSET), b"bytes"); + + // A target whose parent is a regular file cannot be renamed into. + let blocker = root.path().join("blocker"); + write(&blocker, b"file"); + let error = commit(staging, &blocker.join("1.0.0")).expect_err("rename must fail"); + assert!(error.to_string().contains("moved into place"), "{error}"); + assert!(!staged_path.exists(), "the staging directory is cleaned up"); + } + + #[test] + fn a_staging_directory_needs_a_parent() { + assert!(stage(Path::new("/")).is_err()); + } + + #[test] + fn download_urls_follow_the_release_asset_path() { + assert_eq!( + release_download_url("tinyhumansai", "tinymemory", "v1.13.7", "checksum.toml"), + "https://github.com/tinyhumansai/tinymemory/releases/download/v1.13.7/checksum.toml" + ); + } + + #[test] + fn errors_name_the_reason_not_the_path() { + let root = tempfile::tempdir().unwrap(); + let error = find_module(root.path()).expect_err("empty directory"); + let rendered = error.to_string(); + assert!(rendered.contains("no platform module"), "{rendered}"); + assert!( + !rendered.contains(&root.path().display().to_string()), + "{rendered}" + ); + } +} diff --git a/crates/tinybus/src/module/github.rs b/crates/tinybus/src/module/github.rs index 8927fb8..cddb7b4 100644 --- a/crates/tinybus/src/module/github.rs +++ b/crates/tinybus/src/module/github.rs @@ -1,14 +1,77 @@ -//! GitHub release acquisition for verified dynamic modules. +//! GitHub release acquisition for verified dynamic modules. Feature `modules`. +//! +//! Two paths share one verification. [`acquire`] is the original: fetch, +//! verify, extract into a temporary directory the caller keeps alive for the +//! process. [`acquire_cached`] is what a long-lived host wants: the same +//! verification into a directory that survives the process and is found again +//! on the next launch without the network — see [`super::cache`]. +//! +//! # Why the client carries timeouts +//! +//! `ureq`'s default agent has none, and a TCP connect to an address that drops +//! SYNs waits for the operating system to give up — 75 seconds on macOS, longer +//! on Linux — before the next address is tried. GitHub's asset CDN publishes +//! several addresses and a resolver hands them out in a fixed order, so one +//! unreachable address cost every download that full wait, once per module, on +//! every launch. With a connect budget the client divides it across the +//! addresses it was given and moves on to one that answers. +//! +//! # Why asset URLs are built rather than looked up +//! +//! A release asset lives at a fixed path, `releases/download//`. +//! Looking it up through the REST API costs an unauthenticated request against +//! a budget of sixty per hour per address — shared by everyone behind one NAT — +//! and a refusal there is cached by a host as a terminal failure. The API is +//! now the fallback for a release whose direct path answers 404, and nothing +//! else reaches it. use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::time::Duration; use serde::Deserialize; +use tracing::{debug, info, warn}; use crate::error::{Error, Result}; +use crate::module::cache; use crate::module::hash::file_hex; +/// Largest release asset this loader will store. const MAX_RELEASE_BYTES: u64 = 512 * 1024 * 1024; +/// Largest checksum manifest or API listing this loader will read. +const MAX_METADATA_BYTES: u64 = 4 * 1024 * 1024; +const USER_AGENT: &str = "tinybus-module-loader"; +/// Manifest names a release may publish, in the order they are tried. +const CHECKSUM_NAMES: [&str; 2] = ["checksum.toml", "checksum.json"]; + +/// Budgets for one HTTP exchange. The connect budget is what a stalled address +/// costs; it is shared across the resolved addresses, so a dead first address +/// costs roughly half of it rather than the operating system's SYN timeout. +const RESOLVE_TIMEOUT: Duration = Duration::from_secs(10); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const SEND_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const RECV_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30); +/// Bulk transfer of an archive on whatever link the user has. Generous because +/// a slow link is a fact, not a fault; the other budgets bound the stalls. +const RECV_BODY_TIMEOUT: Duration = Duration::from_secs(10 * 60); + +/// A pinned release a host wants loaded through a persistent, verified cache. +#[derive(Debug, Clone)] +pub struct CachedRelease<'a> { + /// The `https://github.com///releases/tag/` page. + pub release_url: &'a str, + /// The archive to load from that release. + pub asset_name: &'a str, + /// The archive digest compiled into the host, if it pins one. Checked + /// against the release's own manifest and against the bytes. + pub expected_sha256: Option<&'a str>, + /// Where the verified archive and its extraction live between launches. + /// One directory per module version; the host names it. + pub cache_dir: &'a Path, + /// Whether a cache miss may reach the network. `false` makes a miss a + /// refusal, for a host whose operator disabled downloads. + pub allow_download: bool, +} #[derive(Debug, Deserialize)] struct Release { @@ -27,15 +90,162 @@ struct Checksums { sha256: HashMap, } -/// Download and extract one platform module from a GitHub release. +/// Where one release's manifest and archive were found. +struct Located { + manifest_name: String, + manifest: Vec, + archive_url: String, +} + +/// Download and extract one platform module from a GitHub release into a +/// temporary directory the caller must keep alive while the module is mapped. pub(crate) fn acquire( release_url: &str, asset_name: &str, expected_sha256: Option<&str>, ) -> Result<(tempfile::TempDir, PathBuf)> { let (owner, repo, tag) = parse_release_url(release_url)?; + let agent = agent(); + let located = locate(&agent, owner, repo, tag, asset_name)?; + let manifest_sha = manifest_digest(&located, asset_name, expected_sha256)?; + + let temp = + tempfile::tempdir().map_err(|_| refused("module extraction directory is unavailable"))?; + let archive_path = temp.path().join(asset_name); + download(&agent, &located.archive_url, &archive_path)?; + verify_archive(&archive_path, &manifest_sha)?; + extract(asset_name, &archive_path, temp.path())?; + let module = canonical_module(cache::find_module(temp.path())?)?; + Ok((temp, module)) +} + +/// Load one platform module from a GitHub release through a persistent cache. +/// +/// Answers from `cache_dir` when it holds the archive intact — verified against +/// the host's pin, or the recorded manifest digest when there is none — and +/// exactly one module. Otherwise, and only when `allow_download` permits, +/// downloads into a staging directory beside `cache_dir`, verifies, extracts, +/// and commits it with one rename. Returns the module's canonical path and the +/// archive digest that was verified, for the caller to carry into attestation. +/// +/// # Errors +/// +/// Returns an error if the release URL is malformed, the cache misses while +/// downloads are disabled, the release cannot be fetched or fails verification, +/// or the cache directory cannot be written. +pub(crate) fn acquire_cached(release: &CachedRelease<'_>) -> Result<(PathBuf, String)> { + let (owner, repo, tag) = parse_release_url(release.release_url)?; + if let Some(hit) = cache::find_verified( + release.cache_dir, + release.asset_name, + release.expected_sha256, + ) { + info!( + asset = release.asset_name, + "release loaded from the local cache" + ); + return Ok((hit.module, hit.sha256)); + } + if !release.allow_download { + return Err(refused("release is not cached and downloads are disabled")); + } + + let agent = agent(); + let located = locate(&agent, owner, repo, tag, release.asset_name)?; + let manifest_sha = manifest_digest(&located, release.asset_name, release.expected_sha256)?; + + let staging = cache::stage(release.cache_dir)?; + let archive_path = staging.path().join(release.asset_name); + download(&agent, &located.archive_url, &archive_path)?; + verify_archive(&archive_path, &manifest_sha)?; + extract(release.asset_name, &archive_path, staging.path())?; + let module = cache::find_module(staging.path())?; + let relative = module + .strip_prefix(staging.path()) + .map_err(|_| refused("release module is outside its staging directory"))? + .to_path_buf(); + cache::write_digest_marker(staging.path(), release.asset_name, &manifest_sha)?; + + if let Err(error) = cache::commit(staging, release.cache_dir) { + // Another process may have committed the same release first; its files + // are as good as ours if they verify. Otherwise the failure stands. + if let Some(hit) = cache::find_verified( + release.cache_dir, + release.asset_name, + release.expected_sha256, + ) { + warn!( + asset = release.asset_name, + "release cache: commit lost a race; using the concurrent fill" + ); + return Ok((hit.module, hit.sha256)); + } + return Err(error); + } + info!( + asset = release.asset_name, + "release downloaded, verified and cached" + ); + let module = canonical_module(release.cache_dir.join(relative))?; + Ok((module, manifest_sha)) +} + +/// One agent per acquisition, so the three requests share a connection pool +/// and every one of them carries the same budgets. +fn agent() -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_resolve(Some(RESOLVE_TIMEOUT)) + .timeout_connect(Some(CONNECT_TIMEOUT)) + .timeout_send_request(Some(SEND_REQUEST_TIMEOUT)) + .timeout_recv_response(Some(RECV_RESPONSE_TIMEOUT)) + .timeout_recv_body(Some(RECV_BODY_TIMEOUT)) + .build() + .new_agent() +} + +/// Find the release's checksum manifest and the archive's URL. +/// +/// The direct download path is tried first, for each manifest name a release +/// may use. Only a 404 there falls back to the REST listing; any other failure +/// is reported, because cascading a stalled connection into further requests +/// would multiply the wait the budgets exist to bound. +fn locate( + agent: &ureq::Agent, + owner: &str, + repo: &str, + tag: &str, + asset_name: &str, +) -> Result { + for name in CHECKSUM_NAMES { + let url = cache::release_download_url(owner, repo, tag, name); + match send(agent, &url, None) { + Ok(response) => { + debug!( + manifest = name, + "release manifest found at its download path" + ); + return Ok(Located { + manifest_name: name.to_string(), + manifest: read_metadata(response)?, + archive_url: cache::release_download_url(owner, repo, tag, asset_name), + }); + } + Err(ureq::Error::StatusCode(404)) => continue, + Err(error) => { + warn!(error = %error, "release manifest could not be downloaded"); + return Err(refused("release checksum manifest could not be downloaded")); + } + } + } + + debug!("release manifest not at its download path; listing the release"); let api_url = format!("https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}"); - let release: Release = get_json(&api_url)?; + let listing = send(agent, &api_url, Some("application/vnd.github+json")).map_err(|error| { + warn!(error = %error, "release listing could not be downloaded"); + refused("GitHub release metadata could not be downloaded") + })?; + let release: Release = serde_json::from_slice(&read_metadata(listing)?) + .map_err(|_| refused("GitHub release metadata is invalid"))?; let assets = release .assets .iter() @@ -44,12 +254,30 @@ pub(crate) fn acquire( let archive = assets .get(asset_name) .ok_or_else(|| refused("requested release asset is missing"))?; - let checksum_asset = ["checksum.toml", "checksum.json"] + let checksum_asset = CHECKSUM_NAMES .iter() .find_map(|name| assets.get(name)) .ok_or_else(|| refused("release checksum manifest is missing"))?; - let checksum_bytes = get_bytes(&checksum_asset.browser_download_url)?; - let checksums = parse_checksums(checksum_asset.name.as_str(), &checksum_bytes)?; + let manifest = send(agent, &checksum_asset.browser_download_url, None) + .map_err(|error| { + warn!(error = %error, "release manifest could not be downloaded"); + refused("release checksum manifest could not be downloaded") + }) + .and_then(read_metadata)?; + Ok(Located { + manifest_name: checksum_asset.name.clone(), + manifest, + archive_url: archive.browser_download_url.clone(), + }) +} + +/// The archive digest the manifest publishes, checked against the host's pin. +fn manifest_digest( + located: &Located, + asset_name: &str, + expected_sha256: Option<&str>, +) -> Result { + let checksums = parse_checksums(&located.manifest_name, &located.manifest)?; let manifest_sha = checksums .get(asset_name) .ok_or_else(|| refused("release asset is absent from its checksum manifest"))?; @@ -60,15 +288,57 @@ pub(crate) fn acquire( return Err(refused("host checksum disagrees with release checksum")); } } + Ok(manifest_sha.to_ascii_lowercase()) +} - let archive_bytes = get_bytes(&archive.browser_download_url)?; - let temp = - tempfile::tempdir().map_err(|_| refused("module extraction directory is unavailable"))?; - let archive_path = temp.path().join(asset_name); - std::fs::write(&archive_path, &archive_bytes) +fn send( + agent: &ureq::Agent, + url: &str, + accept: Option<&str>, +) -> std::result::Result, ureq::Error> { + let mut request = agent.get(url).header("User-Agent", USER_AGENT); + if let Some(accept) = accept { + request = request.header("Accept", accept); + } + request.call() +} + +/// Read a small response — a manifest or a listing — into memory. +fn read_metadata(response: ureq::http::Response) -> Result> { + let mut body = response.into_body(); + let bytes = body + .with_config() + .limit(MAX_METADATA_BYTES + 1) + .read_to_vec() + .map_err(|_| refused("GitHub release metadata could not be read"))?; + if bytes.len() as u64 > MAX_METADATA_BYTES { + return Err(refused("GitHub release metadata exceeds the size cap")); + } + Ok(bytes) +} + +/// Stream an archive to `destination`, never holding it in memory. +fn download(agent: &ureq::Agent, url: &str, destination: &Path) -> Result<()> { + let response = send(agent, url, None).map_err(|error| { + warn!(error = %error, "release asset could not be downloaded"); + refused("GitHub release asset could not be downloaded") + })?; + let mut body = response.into_body(); + let mut reader = body.with_config().limit(MAX_RELEASE_BYTES + 1).reader(); + let mut file = std::fs::File::create(destination) .map_err(|_| refused("release asset could not be stored"))?; + let written = std::io::copy(&mut reader, &mut file) + .map_err(|_| refused("GitHub release asset could not be read"))?; + if written > MAX_RELEASE_BYTES { + return Err(refused("GitHub release asset exceeds the 512 MiB size cap")); + } + debug!(bytes = written, "release asset downloaded"); + Ok(()) +} + +fn verify_archive(archive_path: &Path, manifest_sha: &str) -> Result<()> { let actual = file_hex( - std::fs::File::open(&archive_path) + std::fs::File::open(archive_path) .map_err(|_| refused("release asset could not be read"))?, ) .map_err(|_| refused("release asset hash could not be read"))?; @@ -77,9 +347,7 @@ pub(crate) fn acquire( "release asset hash does not match its checksum manifest", )); } - extract(asset_name, &archive_path, temp.path())?; - let module = canonical_module(find_module(temp.path())?)?; - Ok((temp, module)) + Ok(()) } fn canonical_module(path: PathBuf) -> Result { @@ -108,38 +376,6 @@ fn parse_release_url(url: &str) -> Result<(&str, &str, &str)> { } } -fn get_json Deserialize<'de>>(url: &str) -> Result { - let response = ureq::get(url) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", "tinybus-module-loader") - .call() - .map_err(|_| refused("GitHub release metadata could not be downloaded"))?; - response - .into_body() - .read_to_string() - .map_err(|_| refused("GitHub release metadata could not be read")) - .and_then(|body| { - serde_json::from_str(&body).map_err(|_| refused("GitHub release metadata is invalid")) - }) -} - -fn get_bytes(url: &str) -> Result> { - let response = ureq::get(url) - .header("User-Agent", "tinybus-module-loader") - .call() - .map_err(|_| refused("GitHub release asset could not be downloaded"))?; - let mut body = response.into_body(); - let bytes = body - .with_config() - .limit(MAX_RELEASE_BYTES + 1) - .read_to_vec() - .map_err(|_| refused("GitHub release asset could not be read"))?; - if bytes.len() as u64 > MAX_RELEASE_BYTES { - return Err(refused("GitHub release asset exceeds the 512 MiB size cap")); - } - Ok(bytes) -} - fn parse_checksums(name: &str, bytes: &[u8]) -> Result> { if name.ends_with(".toml") { let source = std::str::from_utf8(bytes).map_err(|_| refused("checksum.toml is invalid"))?; @@ -208,39 +444,6 @@ fn extract(name: &str, archive: &Path, destination: &Path) -> Result<()> { Ok(()) } -fn find_module(root: &Path) -> Result { - let mut found = Vec::new(); - collect_modules(root, &mut found) - .map_err(|_| refused("release archive could not be inspected"))?; - match found.as_slice() { - [module] => Ok(module.clone()), - [] => Err(refused("release archive contains no platform module")), - _ => Err(refused( - "release archive contains multiple platform modules", - )), - } -} - -fn collect_modules(path: &Path, found: &mut Vec) -> std::io::Result<()> { - for entry in std::fs::read_dir(path)? { - let path = entry?.path(); - if path.is_dir() { - collect_modules(&path, found)?; - } else if path.extension().and_then(|value| value.to_str()) - == Some(if cfg!(windows) { - "dll" - } else if cfg!(target_os = "macos") { - "dylib" - } else { - "so" - }) - { - found.push(path); - } - } - Ok(()) -} - fn refused(reason: impl Into) -> Error { Error::module_refused(Path::new("github-release"), reason) } @@ -293,4 +496,66 @@ mod tests { let canonical = canonical_module(module.clone()).unwrap(); assert_eq!(canonical, std::fs::canonicalize(module).unwrap()); } + + #[test] + fn the_manifest_digest_must_agree_with_the_host_pin() { + let digest = "c".repeat(64); + let located = Located { + manifest_name: "checksum.toml".to_string(), + manifest: format!( + "[sha256]\n\"m.tar.gz\" = \"{}\"\n", + digest.to_ascii_uppercase() + ) + .into_bytes(), + archive_url: String::new(), + }; + assert_eq!(manifest_digest(&located, "m.tar.gz", None).unwrap(), digest); + assert_eq!( + manifest_digest(&located, "m.tar.gz", Some(&digest)).unwrap(), + digest + ); + assert!(manifest_digest(&located, "m.tar.gz", Some(&"d".repeat(64))).is_err()); + assert!(manifest_digest(&located, "other.tar.gz", None).is_err()); + assert!(manifest_digest(&located, "m.tar.gz", Some("short")).is_err()); + } + + #[test] + fn a_cache_miss_with_downloads_disabled_is_refused_without_the_network() { + let directory = tempfile::tempdir().unwrap(); + let error = acquire_cached(&CachedRelease { + release_url: "https://github.com/tinyhumansai/demo/releases/tag/v1.0.0", + asset_name: "demo-1.0.0.tar.gz", + expected_sha256: None, + cache_dir: &directory.path().join("demo").join("1.0.0"), + allow_download: false, + }) + .expect_err("nothing cached and no download allowed"); + assert!( + error.to_string().contains("downloads are disabled"), + "{error}" + ); + } + + #[test] + fn a_verified_cache_answers_without_the_network() { + let directory = tempfile::tempdir().unwrap(); + let cache_dir = directory.path().join("demo").join("1.0.0"); + std::fs::create_dir_all(&cache_dir).unwrap(); + std::fs::write(cache_dir.join("demo-1.0.0.tar.gz"), b"archive").unwrap(); + let module = cache_dir.join(format!("libdemo_module.{}", cache::library_extension())); + std::fs::write(&module, b"library").unwrap(); + let digest = crate::module::sha256_file(cache_dir.join("demo-1.0.0.tar.gz")).unwrap(); + + let (found, sha256) = acquire_cached(&CachedRelease { + release_url: "https://github.com/tinyhumansai/demo/releases/tag/v1.0.0", + asset_name: "demo-1.0.0.tar.gz", + expected_sha256: Some(&digest), + cache_dir: &cache_dir, + // Downloads are off, so a hit is the only way this can succeed. + allow_download: false, + }) + .unwrap(); + assert_eq!(found, std::fs::canonicalize(module).unwrap()); + assert_eq!(sha256, digest); + } } diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs index bcfb2c6..78d3e28 100644 --- a/crates/tinybus/src/module/host.rs +++ b/crates/tinybus/src/module/host.rs @@ -13,6 +13,7 @@ use crate::broker::Broker; use crate::build_info; use crate::error::{Error, Result, sanitize_untrusted}; use crate::module::abi::{TB_OK, TbAbiDescriptor, TbModuleInit, TbModuleVtable, field_bytes}; +use crate::module::github::CachedRelease; use crate::module::loader::{self, LoadedArtifact}; use crate::module::manifest::{MANIFEST_SCHEMA, ModuleIdentity, ModuleManifest, PanicPolicy}; use crate::module::transport::ModuleTransport; @@ -341,6 +342,37 @@ impl ModuleHost { Ok(info) } + /// [`ModuleHost::load_github_release`] through a persistent, verified cache. + /// + /// The first load of a release downloads it into `release.cache_dir` — + /// the archive, its extraction, and the digest the release manifest + /// published — after the same two-sided check as the uncached path. Every + /// later load re-hashes the archive on disk against the host's pin and + /// maps the library without touching the network, which is what makes a + /// module cheap to load at every launch rather than once per process. + /// + /// Attestation is the same digest the uncached path records: the archive's, + /// checked against the pin before anything is mapped. The extracted library + /// is held to the release's own `modules.toml` by the ordinary allowlist + /// gate on every load. + /// + /// Nothing is retained here. The directory outlives the process by design, + /// so the sibling files a mapped library may resolve stay where they are. + /// + /// # Errors + /// + /// Returns an error if the release cannot be acquired — see + /// [`CachedRelease`] for when a miss is a refusal — or if the artifact is not + /// admitted. + pub fn load_github_release_cached( + &self, + release: &CachedRelease<'_>, + config: serde_json::Value, + ) -> Result { + let (module, sha256) = crate::module::github::acquire_cached(release)?; + self.load_file_pinned(&module, config, Some(sha256)) + } + /// Admit and initialize an already-resolved module without calling the /// platform loader. /// @@ -410,8 +442,10 @@ impl ModuleHost { /// The verification therefore lives entirely in the caller, and this stays /// private for that reason: exposing it would let a caller declare an /// artifact attested without anyone having hashed anything. The only - /// caller is [`ModuleHost::load_github_release`]; keep it that way, or move - /// the check down here first. + /// callers are [`ModuleHost::load_github_release`] and + /// [`ModuleHost::load_github_release_cached`], both of which hash the + /// archive against the pin first; keep it that way, or move the check down + /// here first. fn load_file_pinned( &self, path: impl AsRef, diff --git a/crates/tinybus/src/module/mod.rs b/crates/tinybus/src/module/mod.rs index a7f1735..5d7a9c9 100644 --- a/crates/tinybus/src/module/mod.rs +++ b/crates/tinybus/src/module/mod.rs @@ -6,6 +6,8 @@ pub mod abi; #[cfg(feature = "modules")] +mod cache; +#[cfg(feature = "modules")] mod github; mod hash; pub mod manifest; @@ -19,6 +21,8 @@ mod resolve; #[cfg(feature = "modules")] mod transport; +#[cfg(feature = "modules")] +pub use github::CachedRelease; #[cfg(feature = "modules")] pub use host::{ModuleHost, ModuleInfo, ModuleState}; diff --git a/docs/modules/module/README.md b/docs/modules/module/README.md index 45bbddc..89995cf 100644 --- a/docs/modules/module/README.md +++ b/docs/modules/module/README.md @@ -108,6 +108,26 @@ tinybus modules checksum \ --output checksum.toml ``` +## Release cache + +`ModuleHost::load_github_release_cached` is `load_github_release` with a +directory that outlives the process. The host names it — one per module +version — and the first load downloads into a staging directory beside it, +verifies the archive against the release's `checksum.toml` and the host's pin, +extracts, records the manifest digest beside the archive, and commits with one +rename. Every later load re-hashes the archive on disk against the pin, checks +the extracted library against the release's own `modules.toml`, and maps it +without touching the network. A directory that fails either check is a miss, +not a refusal: the next download replaces it. + +Asset URLs are built from the tag rather than looked up through the REST API, +whose unauthenticated budget is shared by everyone behind one address; the API +is only the fallback for a release whose direct path answers 404. Every +request carries resolve, connect and response budgets, so an address that +drops packets costs seconds rather than the operating system's SYN timeout. +Set `allow_download` to `false` to make a miss a refusal, for a host whose +operator has disabled downloads. + Refusing one artifact does not prevent the host from admitting other artifacts in the same directory. The refused artifact's error contains only a sanitized basename and fixed reason. From b78bcc53978ca5b6ae05bdc3587b435c072e8bef Mon Sep 17 00:00:00 2001 From: Shanu Date: Thu, 3 Sep 2026 23:16:32 +0530 Subject: [PATCH 2/2] Cover the startup refusals a host vtable can trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tinybus-module/src/lib.rs` sat at exactly 576/640 = 90.00% lines, the boundary `--fail-under-file-lines 90` compares against. Line coverage also differs by three lines between Linux and macOS, so the file could land on either side of the gate depending on the runner — it went red on this PR without the diff touching that crate. The uncovered region was worth a test rather than a nudge: the argument validation in `start_module_with_config` is the first code a `dlopen`ed module runs, it is handed a raw pointer by the host, and every branch there stands between a malformed descriptor and a dereference. None of it was exercised. Cover all five refusals — a null vtable, one shorter than this ABI's, a config length with no buffer behind it, a config past the 1 MiB cap, and bytes that do not deserialize into the module's own config type. Each returns before a runtime, a panic hook or a transport exists, so the test needs no host-state guard and cannot perturb the process-global runtime capture the other startup test relies on. The file moves to 630/677 = 93.06%, which is far enough from the boundary that the platform delta no longer decides it. --- crates/tinybus-module/src/lib.rs | 92 ++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/crates/tinybus-module/src/lib.rs b/crates/tinybus-module/src/lib.rs index 3cf3aa3..da95a3b 100644 --- a/crates/tinybus-module/src/lib.rs +++ b/crates/tinybus-module/src/lib.rs @@ -1004,4 +1004,96 @@ mod tests { assert_eq!(unsafe { (out.shutdown)(out.module_ctx, 10) }, TB_OK); assert_eq!(unsafe { (out.shutdown)(out.module_ctx, 10) }, TB_CLOSED); } + + /// Startup refuses a host vtable it cannot trust, before it builds anything. + /// + /// Every branch here returns before a runtime, a panic hook or a transport + /// exists, which is the property worth pinning: this is the first code a + /// `dlopen`ed module runs, it is handed a raw pointer by the host, and the + /// refusals are what stand between a malformed descriptor and a + /// dereference. A regression would not fail loudly — it would read off the + /// end of a struct the host never filled in. + /// + /// Deliberately takes no host-state guard: none of these paths touches the + /// shared statics or the module's process-global runtime capture, because + /// none of them gets far enough to. + #[test] + fn configured_startup_refuses_a_host_vtable_it_cannot_trust() { + fn attempt(host: *const TbHostVtable) -> i32 { + let mut out = TbModuleVtable::default(); + unsafe { + start_module_with_config::( + host, + &mut out, + 1, + true, + // Never runs: every case below is refused before setup. + |_, _| async { Ok(()) }, + ) + } + } + + assert_eq!( + attempt(std::ptr::null()), + TB_BAD_ARGUMENT, + "a null host vtable is refused rather than dereferenced" + ); + + // A host built against an older, smaller descriptor. Reading our + // fields out of it would run off the end of what it allocated. + let mut truncated = host(b"{}"); + truncated.size = (size_of::() - 1) as u32; + assert_eq!( + attempt(&truncated), + TB_BAD_ARGUMENT, + "a vtable shorter than this ABI's is refused" + ); + + // A length with no buffer behind it. + let mut dangling = host(b"{}"); + dangling.config.ptr = std::ptr::null(); + dangling.config.len = 8; + assert_eq!( + attempt(&dangling), + TB_BAD_ARGUMENT, + "a config length with a null pointer is refused" + ); + + // Past the 1 MiB config cap. The pointer is valid; only the claimed + // length is not, so this asserts the cap and not the null check. + let real = b"{}"; + let mut oversized = host(real); + oversized.config.len = 1024 * 1024 + 1; + assert_eq!( + attempt(&oversized), + TB_BAD_ARGUMENT, + "a config past the size cap is refused" + ); + + // Well-formed descriptor, unparseable config for the declared type. + let malformed = host(b"not json"); + assert_eq!( + attempt(&malformed), + TB_BAD_ARGUMENT, + "a config that does not deserialize is refused" + ); + + // A config the module cannot deserialize into its own type is the same + // refusal, which is what stops a type mismatch reaching `setup`. + let wrong_shape = host(br#"{"answer":42}"#); + let mut out = TbModuleVtable::default(); + assert_eq!( + unsafe { + start_module_with_config::, _, _>( + &wrong_shape, + &mut out, + 1, + true, + |_, _| async { Ok(()) }, + ) + }, + TB_BAD_ARGUMENT, + "an object is not a Vec, and that is caught before setup" + ); + } }