//! Explicit runtime provisioning. These blocking setup calls never run from a
//! document method; async applications may use `tokio::task::spawn_blocking`.
use crate::{Error, HostMode, HostOptions, Result};
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, BTreeSet},
fs::{self, File, OpenOptions},
io::{Read, Write},
path::{Path, PathBuf},
time::{Duration, Instant},
};
const STAGE_PREFIX: &str = ".superdoc-runtime-stage-";
const STAGE_OWNER: &[u8] = b"superdoc-sdk-runtime-stage-v1\n";
const MAX_ARCHIVE: u64 = 512 * 1024 * 1024;
const MAX_EXPANDED: u64 = 1024 * 1024 * 1024;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RuntimeManifest {
pub format_version: u32,
pub crate_version: String,
pub runtime_version: String,
pub release_base_url: String,
pub contract_hash: String,
pub targets: BTreeMap<String, RuntimeTarget>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RuntimeTarget {
pub rust_target: String,
pub archive: String,
pub sha256: String,
pub size: u64,
pub cli: String,
pub document_host: String,
pub files: BTreeMap<String, RuntimeFile>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RuntimeFile {
pub sha256: String,
pub size: u64,
pub executable: bool,
}
#[derive(Clone, Debug)]
pub struct Installation {
directory: PathBuf,
target: String,
manifest: RuntimeManifest,
}
fn failure(message: &str) -> Error {
Error::new("RUNTIME_INSTALLATION_FAILED", message)
}
fn io(_: std::io::Error) -> Error {
failure(
"runtime filesystem operation failed; check paths, permissions and available disk space",
)
}
fn sync_directory(path: &Path) -> Result<()> {
#[cfg(unix)]
File::open(path).map_err(io)?.sync_all().map_err(io)?;
#[cfg(not(unix))]
let _ = path;
Ok(())
}
fn hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn safe_name(name: &str) -> bool {
!name.is_empty()
&& ![".complete", ".stage-owner"].contains(&name)
&& !name.chars().any(char::is_control)
&& !name.contains('\\')
&& !name.contains(':')
&& name.split('/').all(|part| {
let base = part.split('.').next().unwrap_or("").to_ascii_uppercase();
!part.is_empty()
&& part != "."
&& part != ".."
&& !part.ends_with(['.', ' '])
&& ![
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6",
"COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7",
"LPT8", "LPT9",
]
.contains(&base.as_str())
})
}
fn digest_file(path: &Path, expected: &RuntimeFile) -> Result<()> {
let metadata = fs::symlink_metadata(path).map_err(io)?;
if !metadata.is_file() || metadata.len() != expected.size {
return Err(failure("installed runtime file type or size mismatch"));
}
let mut file = File::open(path).map_err(io)?;
let mut digest = Sha256::new();
std::io::copy(&mut file, &mut digest).map_err(io)?;
if format!("{:x}", digest.finalize()) != expected.sha256 {
return Err(failure("installed runtime file checksum mismatch"));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if expected.executable && metadata.permissions().mode() & 0o111 == 0 {
return Err(failure(
"installed runtime executable permission is missing",
));
}
}
Ok(())
}
/// Explicit target mapping. Linux bundles require glibc; musl is unsupported.
pub fn host_target() -> Result<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => Ok("darwin-arm64"),
("macos", "x86_64") => Ok("darwin-x64"),
("linux", "aarch64") if cfg!(target_env = "gnu") => Ok("linux-arm64"),
("linux", "x86_64") if cfg!(target_env = "gnu") => Ok("linux-x64"),
("windows", "x86_64") if cfg!(target_env = "msvc") => Ok("windows-x64"),
_ => Err(Error::new(
"PLATFORM_UNSUPPORTED",
"no qualified SuperDoc runtime exists for this target",
)),
}
}
impl RuntimeManifest {
/// The release adapter embeds immutable archive digests into the crate.
pub fn pinned() -> Result<Self> {
Self::from_json(include_bytes!("../../assets/runtime-manifest.json"))
}
/// Accept only manifests obtained through a trusted, independently verified
/// release channel. An adjacent downloaded checksum is not a trust anchor.
pub fn from_json(bytes: &[u8]) -> Result<Self> {
if bytes.len() > 1024 * 1024 {
return Err(failure("runtime manifest exceeds size limit"));
}
let value: Self =
serde_json::from_slice(bytes).map_err(|_| failure("runtime manifest is malformed"))?;
value.validate()?;
Ok(value)
}
fn validate(&self) -> Result<()> {
let value = self;
if value.format_version != 1
|| value.crate_version != env!("CARGO_PKG_VERSION")
|| value.contract_hash != crate::generated::CONTRACT_HASH
|| (!safe_name(&value.runtime_version) || value.runtime_version.contains('/'))
|| value.targets.is_empty()
{
return Err(failure(
"runtime manifest identity does not match this crate",
));
}
for (name, target) in &value.targets {
if ![
"darwin-arm64",
"darwin-x64",
"linux-x64",
"linux-arm64",
"windows-x64",
]
.contains(&name.as_str())
|| (!safe_name(&target.archive) || target.archive.contains('/'))
|| !safe_name(&target.cli)
|| !safe_name(&target.document_host)
|| target.size == 0
|| target.size > MAX_ARCHIVE
|| target.sha256.len() != 64
|| !target.sha256.bytes().all(|b| b.is_ascii_hexdigit())
|| target.files.is_empty()
|| target.files.len() > 4096
{
return Err(failure("runtime target manifest is invalid"));
}
let expected_rust = match name.as_str() {
"darwin-arm64" => "aarch64-apple-darwin",
"darwin-x64" => "x86_64-apple-darwin",
"linux-x64" => "x86_64-unknown-linux-gnu",
"linux-arm64" => "aarch64-unknown-linux-gnu",
"windows-x64" => "x86_64-pc-windows-msvc",
_ => return Err(failure("runtime target is unsupported")),
};
if target.rust_target != expected_rust {
return Err(failure(
"Rust deployment target does not match runtime target",
));
}
if !target.files.contains_key(&target.cli)
|| !target.files.contains_key(&target.document_host)
{
return Err(failure("runtime manifest is missing a host"));
}
let mut size = 0u64;
for (name, file) in &target.files {
size = size
.checked_add(file.size)
.ok_or_else(|| failure("runtime expanded size overflow"))?;
if !safe_name(name)
|| file.sha256.len() != 64
|| !file.sha256.bytes().all(|b| b.is_ascii_hexdigit())
{
return Err(failure("runtime file manifest is invalid"));
}
}
if size > MAX_EXPANDED {
return Err(failure("runtime expanded size exceeds limit"));
}
}
Ok(())
}
fn target(&self, target: &str) -> Result<&RuntimeTarget> {
self.validate()?;
self.targets.get(target).ok_or_else(|| {
Error::new(
"PLATFORM_UNSUPPORTED",
"runtime target is absent from this pinned release",
)
})
}
fn destination(&self, cache: &Path, target: &str) -> Result<PathBuf> {
Ok(cache.join(&self.runtime_version).join(format!(
"{}-{}",
target,
self.target(target)?.sha256
)))
}
fn installation_identity(&self, target: &str) -> Result<String> {
self.identity_for_pin(target, self.target(target)?)
}
fn identity_for_pin(&self, target: &str, pin: &RuntimeTarget) -> Result<String> {
// Serialize structs and BTreeMaps directly so a consumer enabling
// serde_json/preserve_order cannot change a shared cache identity.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Identity<'a> {
format_version: u32,
runtime_version: &'a str,
contract_hash: &'a str,
target: &'a str,
pin: &'a RuntimeTarget,
}
let identity = Identity {
format_version: 1,
runtime_version: &self.runtime_version,
contract_hash: &self.contract_hash,
target,
pin,
};
Ok(hash(&serde_json::to_vec(&identity).map_err(|_| {
failure("could not encode runtime identity")
})?))
}
/// Verify and resolve an existing installation, without network access.
pub fn resolve(&self, cache: &Path, target: &str) -> Result<Installation> {
let directory = self.destination(cache, target)?;
let entry = self.target(target)?;
let expected = self.installation_identity(target)?;
let marker = directory.join(".complete");
let metadata = fs::symlink_metadata(&marker).map_err(io)?;
if !metadata.is_file()
|| metadata.len() != 64
|| fs::read(marker).map_err(io)? != expected.as_bytes()
{
return Err(failure(
"runtime installation is incomplete or belongs to another manifest",
));
}
verify_inventory(&directory, &entry.files)?;
for (name, file) in &entry.files {
verify_parents(&directory, name)?;
digest_file(&directory.join(name), file)?;
}
Ok(Installation {
directory,
target: target.into(),
manifest: self.clone(),
})
}
/// Install a local archive with digests already pinned in this manifest.
/// The explicit target supports cross-provisioning without executing it.
pub fn install_archive(
&self,
cache: &Path,
target: &str,
archive: &Path,
) -> Result<Installation> {
let entry = self.target(target)?;
let destination = self.destination(cache, target)?;
fs::create_dir_all(cache).map_err(io)?;
let lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(cache.join(".install.lock"))
.map_err(io)?;
let until = Instant::now() + Duration::from_secs(60);
loop {
match lock.try_lock_exclusive() {
Ok(()) => break,
Err(error)
if (error.kind() == std::io::ErrorKind::WouldBlock
|| error.raw_os_error() == fs2::lock_contended_error().raw_os_error())
&& Instant::now() < until =>
{
std::thread::sleep(Duration::from_millis(20))
}
Err(_) => return Err(failure("could not acquire runtime installation lock")),
}
}
recover_staging(cache)?;
if destination.exists() {
return self.resolve(cache, target);
}
let mut source = File::open(archive).map_err(io)?;
if source.metadata().map_err(io)?.len() != entry.size {
return Err(failure("runtime archive size mismatch"));
}
// Keep the verified archive handle and stream to a private temporary
// file; never reopen an attacker-swappable archive path for extraction.
let mut staged_archive = tempfile::tempfile().map_err(io)?;
let mut digest = Sha256::new();
let mut total = 0u64;
let mut buffer = [0u8; 64 * 1024];
loop {
let count = source.read(&mut buffer).map_err(io)?;
if count == 0 {
break;
}
total += count as u64;
if total > entry.size {
return Err(failure("runtime archive exceeds size limit"));
}
digest.update(&buffer[..count]);
staged_archive.write_all(&buffer[..count]).map_err(io)?;
}
if total != entry.size || format!("{:x}", digest.finalize()) != entry.sha256 {
return Err(failure("runtime archive checksum mismatch"));
}
use std::io::Seek;
staged_archive.rewind().map_err(io)?;
let parent = destination
.parent()
.ok_or_else(|| failure("runtime destination is invalid"))?;
fs::create_dir_all(parent).map_err(io)?;
let stage = tempfile::Builder::new()
.prefix(STAGE_PREFIX)
.tempdir_in(cache)
.map_err(io)?;
let mut owner = File::create(stage.path().join(".stage-owner")).map_err(io)?;
owner.write_all(STAGE_OWNER).map_err(io)?;
owner.sync_all().map_err(io)?;
sync_directory(stage.path())?;
sync_directory(cache)?;
// USTAR has one 512-byte header and padded data blocks per file,
// two end blocks, then padding to GNU tar's 20-block record size.
let blocks: u64 = entry
.files
.values()
.map(|file| 512 + file.size.div_ceil(512) * 512)
.sum();
let expanded_limit = (blocks + 1024).div_ceil(10_240) * 10_240;
let decoder = flate2::read::MultiGzDecoder::new(staged_archive).take(expanded_limit + 1);
let mut tar = tar::Archive::new(decoder);
let mut seen = BTreeSet::new();
// Raw iteration prevents tar from allocating GNU/PAX extension bodies
// before the SDK has inspected their header and rejected them.
for item in tar.entries().map_err(io)?.raw(true) {
let mut item = item.map_err(io)?;
let name = item
.path()
.map_err(io)?
.to_str()
.ok_or_else(|| failure("runtime archive path must be UTF-8"))?
.to_owned();
if !safe_name(&name)
|| !item.header().entry_type().is_file()
|| !seen.insert(name.clone())
{
return Err(failure(
"runtime archive contains an unsafe, duplicate or non-file entry",
));
}
let expected = entry
.files
.get(&name)
.ok_or_else(|| failure("runtime archive contains an undeclared file"))?;
if item.size() != expected.size {
return Err(failure("runtime file size mismatch"));
}
let output = stage.path().join(&name);
fs::create_dir_all(output.parent().unwrap()).map_err(io)?;
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&output)
.map_err(io)?;
std::io::copy(&mut item, &mut file).map_err(io)?;
file.sync_all().map_err(io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(
&output,
fs::Permissions::from_mode(if expected.executable { 0o755 } else { 0o644 }),
)
.map_err(io)?;
}
file.sync_all().map_err(io)?;
digest_file(&output, expected)?;
}
let mut remainder = tar.into_inner();
let mut padding = [0u8; 8192];
loop {
let count = remainder.read(&mut padding).map_err(io)?;
if count == 0 {
break;
}
if padding[..count].iter().any(|byte| *byte != 0) {
return Err(failure(
"runtime archive contains data after its end marker",
));
}
}
if remainder.limit() == 0 {
return Err(failure(
"runtime decompression exceeded the declared inventory bound",
));
}
if seen.len() != entry.files.len() {
return Err(failure("runtime archive is missing required files"));
}
#[cfg(unix)]
{
let mut directories = std::collections::BTreeSet::new();
for name in entry.files.keys() {
let mut parent = stage.path().join(name);
parent.pop();
while parent.starts_with(stage.path()) {
directories.insert(parent.clone());
if !parent.pop() {
break;
}
}
}
for directory in directories.iter().rev() {
File::open(directory).map_err(io)?.sync_all().map_err(io)?;
}
}
let identity = self.installation_identity(target)?;
let mut marker = File::create(stage.path().join(".complete")).map_err(io)?;
marker.write_all(identity.as_bytes()).map_err(io)?;
marker.sync_all().map_err(io)?;
#[cfg(unix)]
File::open(stage.path())
.map_err(io)?
.sync_all()
.map_err(io)?;
fs::rename(stage.path(), &destination).map_err(io)?;
#[cfg(unix)]
File::open(destination.parent().unwrap())
.map_err(io)?
.sync_all()
.map_err(io)?;
#[cfg(unix)]
File::open(cache).map_err(io)?.sync_all().map_err(io)?;
self.resolve(cache, target)
}
/// Explicit, bounded HTTPS provisioning. Proxy settings follow ureq's
/// standard environment support. Document operations never call this.
pub fn install(&self, cache: &Path, target: &str) -> Result<Installation> {
let entry = self.target(target)?;
if !self.release_base_url.starts_with("https://")
|| self.release_base_url.contains('?')
|| self.release_base_url.contains('#')
{
return Err(failure(
"runtime release endpoint must be an immutable HTTPS URL",
));
}
let url = format!(
"{}/{}",
self.release_base_url.trim_end_matches('/'),
entry.archive
);
let agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(120)))
.max_redirects(5)
.build()
.new_agent();
let mut response = agent.get(&url).call().map_err(|_| {
failure("runtime download failed; provision the pinned archive offline if needed")
})?;
let mut staged = tempfile::NamedTempFile::new().map_err(io)?;
let copied = std::io::copy(
&mut response.body_mut().as_reader().take(entry.size + 1),
&mut staged,
)
.map_err(io)?;
if copied != entry.size {
return Err(failure(
"runtime download was truncated or exceeded its pinned size",
));
}
staged.flush().map_err(io)?;
self.install_archive(cache, target, staged.path())
}
}
// The global cache lock is held. Only this versioned marker and namespace
// identify a disposable staging directory; never remove unrelated cache data.
fn recover_staging(cache: &Path) -> Result<()> {
for entry in fs::read_dir(cache).map_err(io)? {
let entry = entry.map_err(io)?;
if !entry
.file_name()
.to_string_lossy()
.starts_with(STAGE_PREFIX)
|| !entry.file_type().map_err(io)?.is_dir()
{
continue;
}
let marker = entry.path().join(".stage-owner");
let Ok(metadata) = fs::symlink_metadata(&marker) else {
continue;
};
if metadata.is_file()
&& metadata.len() == STAGE_OWNER.len() as u64
&& fs::read(marker).map_err(io)? == STAGE_OWNER
{
fs::remove_dir_all(entry.path()).map_err(io)?;
}
}
Ok(())
}
fn verify_inventory(root: &Path, files: &BTreeMap<String, RuntimeFile>) -> Result<()> {
if !fs::symlink_metadata(root).map_err(io)?.is_dir() {
return Err(failure("runtime root must be a physical directory"));
}
let mut directories = BTreeSet::new();
for name in files.keys() {
let mut parent = Path::new(name).parent();
while let Some(path) = parent {
if path.as_os_str().is_empty() {
break;
}
directories.insert(path.to_path_buf());
parent = path.parent();
}
}
let mut pending = vec![PathBuf::new()];
while let Some(relative) = pending.pop() {
for entry in fs::read_dir(root.join(&relative)).map_err(io)? {
let entry = entry.map_err(io)?;
let path = relative.join(entry.file_name());
let kind = entry.file_type().map_err(io)?;
if kind.is_dir() && directories.contains(&path) {
pending.push(path);
continue;
}
let name = path
.to_str()
.ok_or_else(|| failure("installed runtime path must be UTF-8"))?
.replace('\\', "/");
if !kind.is_file()
|| (!files.contains_key(&name)
&& !matches!(name.as_str(), ".complete" | ".stage-owner"))
{
return Err(failure(
"installed runtime contains an undeclared file or directory",
));
}
if name == ".stage-owner" && fs::read(entry.path()).map_err(io)? != STAGE_OWNER {
return Err(failure("installed runtime staging identity mismatch"));
}
}
}
Ok(())
}
fn verify_parents(root: &Path, name: &str) -> Result<()> {
let mut path = root.to_path_buf();
if !fs::symlink_metadata(&path).map_err(io)?.is_dir() {
return Err(failure("runtime root must be a physical directory"));
}
for component in name.split('/').take(name.split('/').count() - 1) {
path.push(component);
if !fs::symlink_metadata(&path).map_err(io)?.is_dir() {
return Err(failure("runtime parent must be a physical directory"));
}
}
Ok(())
}
impl Installation {
pub fn directory(&self) -> &Path {
&self.directory
}
pub fn runtime_version(&self) -> &str {
&self.manifest.runtime_version
}
pub fn target(&self) -> &str {
&self.target
}
pub fn host_options(&self, mode: HostMode) -> Result<HostOptions> {
if self.target != host_target()? {
return Err(Error::new(
"PLATFORM_UNSUPPORTED",
"installed runtime cannot execute on this build target",
));
}
let target = self.manifest.target(&self.target)?;
for (name, file) in &target.files {
verify_parents(&self.directory, name)?;
digest_file(&self.directory.join(name), file)?;
}
let mut options = HostOptions::new(self.directory.join(if mode == HostMode::Cli {
&target.cli
} else {
&target.document_host
}));
options.mode = mode;
Ok(options)
}
}
#[cfg(test)]
mod identity_tests {
use super::*;
#[test]
fn installation_identity_is_stable_across_serde_map_features() {
let manifest: RuntimeManifest = serde_json::from_str(r#"{"formatVersion":1,"crateVersion":"0.1.0-alpha.1","runtimeVersion":"2.20.1-next.8","releaseBaseUrl":"https://example.test","contractHash":"0000000000000000000000000000000000000000000000000000000000000000","targets":{"linux-x64":{"rustTarget":"x86_64-unknown-linux-gnu","archive":"runtime.tar.gz","sha256":"1111111111111111111111111111111111111111111111111111111111111111","size":2,"cli":"superdoc","documentHost":"superdoc-document-host","files":{"superdoc":{"sha256":"2222222222222222222222222222222222222222222222222222222222222222","size":1,"executable":true},"superdoc-document-host":{"sha256":"2222222222222222222222222222222222222222222222222222222222222222","size":1,"executable":true}}}}}"#).unwrap();
assert_eq!(
manifest
.identity_for_pin("linux-x64", &manifest.targets["linux-x64"])
.unwrap(),
"0edc9ed2a0621331327f28c9d8b44a0759aee20fb1c5056f849d741bb4afd76e"
);
}
}