Skip to main content

superdoc_sdk/runtime/
installation.rs

1//! Explicit runtime provisioning. These blocking setup calls never run from a
2//! document method; async applications may use `tokio::task::spawn_blocking`.
3use crate::{Error, HostMode, HostOptions, Result};
4use fs2::FileExt;
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::{
8    collections::{BTreeMap, BTreeSet},
9    fs::{self, File, OpenOptions},
10    io::{Read, Write},
11    path::{Path, PathBuf},
12    time::{Duration, Instant},
13};
14const STAGE_PREFIX: &str = ".superdoc-runtime-stage-";
15const STAGE_OWNER: &[u8] = b"superdoc-sdk-runtime-stage-v1\n";
16const MAX_ARCHIVE: u64 = 512 * 1024 * 1024;
17const MAX_EXPANDED: u64 = 1024 * 1024 * 1024;
18#[derive(Clone, Debug, Deserialize, Serialize)]
19#[serde(rename_all = "camelCase", deny_unknown_fields)]
20pub struct RuntimeManifest {
21    pub format_version: u32,
22    pub crate_version: String,
23    pub runtime_version: String,
24    pub release_base_url: String,
25    pub contract_hash: String,
26    pub targets: BTreeMap<String, RuntimeTarget>,
27}
28#[derive(Clone, Debug, Deserialize, Serialize)]
29#[serde(rename_all = "camelCase", deny_unknown_fields)]
30pub struct RuntimeTarget {
31    pub rust_target: String,
32    pub archive: String,
33    pub sha256: String,
34    pub size: u64,
35    pub cli: String,
36    pub document_host: String,
37    pub files: BTreeMap<String, RuntimeFile>,
38}
39#[derive(Clone, Debug, Deserialize, Serialize)]
40#[serde(rename_all = "camelCase", deny_unknown_fields)]
41pub struct RuntimeFile {
42    pub sha256: String,
43    pub size: u64,
44    pub executable: bool,
45}
46#[derive(Clone, Debug)]
47pub struct Installation {
48    directory: PathBuf,
49    target: String,
50    manifest: RuntimeManifest,
51}
52fn failure(message: &str) -> Error {
53    Error::new("RUNTIME_INSTALLATION_FAILED", message)
54}
55fn io(_: std::io::Error) -> Error {
56    failure(
57        "runtime filesystem operation failed; check paths, permissions and available disk space",
58    )
59}
60fn sync_directory(path: &Path) -> Result<()> {
61    #[cfg(unix)]
62    File::open(path).map_err(io)?.sync_all().map_err(io)?;
63    #[cfg(not(unix))]
64    let _ = path;
65    Ok(())
66}
67fn hash(bytes: &[u8]) -> String {
68    format!("{:x}", Sha256::digest(bytes))
69}
70fn safe_name(name: &str) -> bool {
71    !name.is_empty()
72        && ![".complete", ".stage-owner"].contains(&name)
73        && !name.chars().any(char::is_control)
74        && !name.contains('\\')
75        && !name.contains(':')
76        && name.split('/').all(|part| {
77            let base = part.split('.').next().unwrap_or("").to_ascii_uppercase();
78            !part.is_empty()
79                && part != "."
80                && part != ".."
81                && !part.ends_with(['.', ' '])
82                && ![
83                    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6",
84                    "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7",
85                    "LPT8", "LPT9",
86                ]
87                .contains(&base.as_str())
88        })
89}
90fn digest_file(path: &Path, expected: &RuntimeFile) -> Result<()> {
91    let metadata = fs::symlink_metadata(path).map_err(io)?;
92    if !metadata.is_file() || metadata.len() != expected.size {
93        return Err(failure("installed runtime file type or size mismatch"));
94    }
95    let mut file = File::open(path).map_err(io)?;
96    let mut digest = Sha256::new();
97    std::io::copy(&mut file, &mut digest).map_err(io)?;
98    if format!("{:x}", digest.finalize()) != expected.sha256 {
99        return Err(failure("installed runtime file checksum mismatch"));
100    }
101    #[cfg(unix)]
102    {
103        use std::os::unix::fs::PermissionsExt;
104        if expected.executable && metadata.permissions().mode() & 0o111 == 0 {
105            return Err(failure(
106                "installed runtime executable permission is missing",
107            ));
108        }
109    }
110    Ok(())
111}
112/// Explicit target mapping. Linux bundles require glibc; musl is unsupported.
113pub fn host_target() -> Result<&'static str> {
114    match (std::env::consts::OS, std::env::consts::ARCH) {
115        ("macos", "aarch64") => Ok("darwin-arm64"),
116        ("macos", "x86_64") => Ok("darwin-x64"),
117        ("linux", "aarch64") if cfg!(target_env = "gnu") => Ok("linux-arm64"),
118        ("linux", "x86_64") if cfg!(target_env = "gnu") => Ok("linux-x64"),
119        ("windows", "x86_64") if cfg!(target_env = "msvc") => Ok("windows-x64"),
120        _ => Err(Error::new(
121            "PLATFORM_UNSUPPORTED",
122            "no qualified SuperDoc runtime exists for this target",
123        )),
124    }
125}
126impl RuntimeManifest {
127    /// The release adapter embeds immutable archive digests into the crate.
128    pub fn pinned() -> Result<Self> {
129        Self::from_json(include_bytes!("../../assets/runtime-manifest.json"))
130    }
131    /// Accept only manifests obtained through a trusted, independently verified
132    /// release channel. An adjacent downloaded checksum is not a trust anchor.
133    pub fn from_json(bytes: &[u8]) -> Result<Self> {
134        if bytes.len() > 1024 * 1024 {
135            return Err(failure("runtime manifest exceeds size limit"));
136        }
137        let value: Self =
138            serde_json::from_slice(bytes).map_err(|_| failure("runtime manifest is malformed"))?;
139        value.validate()?;
140        Ok(value)
141    }
142    fn validate(&self) -> Result<()> {
143        let value = self;
144        if value.format_version != 1
145            || value.crate_version != env!("CARGO_PKG_VERSION")
146            || value.contract_hash != crate::generated::CONTRACT_HASH
147            || (!safe_name(&value.runtime_version) || value.runtime_version.contains('/'))
148            || value.targets.is_empty()
149        {
150            return Err(failure(
151                "runtime manifest identity does not match this crate",
152            ));
153        }
154        for (name, target) in &value.targets {
155            if ![
156                "darwin-arm64",
157                "darwin-x64",
158                "linux-x64",
159                "linux-arm64",
160                "windows-x64",
161            ]
162            .contains(&name.as_str())
163                || (!safe_name(&target.archive) || target.archive.contains('/'))
164                || !safe_name(&target.cli)
165                || !safe_name(&target.document_host)
166                || target.size == 0
167                || target.size > MAX_ARCHIVE
168                || target.sha256.len() != 64
169                || !target.sha256.bytes().all(|b| b.is_ascii_hexdigit())
170                || target.files.is_empty()
171                || target.files.len() > 4096
172            {
173                return Err(failure("runtime target manifest is invalid"));
174            }
175            let expected_rust = match name.as_str() {
176                "darwin-arm64" => "aarch64-apple-darwin",
177                "darwin-x64" => "x86_64-apple-darwin",
178                "linux-x64" => "x86_64-unknown-linux-gnu",
179                "linux-arm64" => "aarch64-unknown-linux-gnu",
180                "windows-x64" => "x86_64-pc-windows-msvc",
181                _ => return Err(failure("runtime target is unsupported")),
182            };
183            if target.rust_target != expected_rust {
184                return Err(failure(
185                    "Rust deployment target does not match runtime target",
186                ));
187            }
188            if !target.files.contains_key(&target.cli)
189                || !target.files.contains_key(&target.document_host)
190            {
191                return Err(failure("runtime manifest is missing a host"));
192            }
193            let mut size = 0u64;
194            for (name, file) in &target.files {
195                size = size
196                    .checked_add(file.size)
197                    .ok_or_else(|| failure("runtime expanded size overflow"))?;
198                if !safe_name(name)
199                    || file.sha256.len() != 64
200                    || !file.sha256.bytes().all(|b| b.is_ascii_hexdigit())
201                {
202                    return Err(failure("runtime file manifest is invalid"));
203                }
204            }
205            if size > MAX_EXPANDED {
206                return Err(failure("runtime expanded size exceeds limit"));
207            }
208        }
209        Ok(())
210    }
211    fn target(&self, target: &str) -> Result<&RuntimeTarget> {
212        self.validate()?;
213        self.targets.get(target).ok_or_else(|| {
214            Error::new(
215                "PLATFORM_UNSUPPORTED",
216                "runtime target is absent from this pinned release",
217            )
218        })
219    }
220    fn destination(&self, cache: &Path, target: &str) -> Result<PathBuf> {
221        Ok(cache.join(&self.runtime_version).join(format!(
222            "{}-{}",
223            target,
224            self.target(target)?.sha256
225        )))
226    }
227    fn installation_identity(&self, target: &str) -> Result<String> {
228        self.identity_for_pin(target, self.target(target)?)
229    }
230    fn identity_for_pin(&self, target: &str, pin: &RuntimeTarget) -> Result<String> {
231        // Serialize structs and BTreeMaps directly so a consumer enabling
232        // serde_json/preserve_order cannot change a shared cache identity.
233        #[derive(Serialize)]
234        #[serde(rename_all = "camelCase")]
235        struct Identity<'a> {
236            format_version: u32,
237            runtime_version: &'a str,
238            contract_hash: &'a str,
239            target: &'a str,
240            pin: &'a RuntimeTarget,
241        }
242        let identity = Identity {
243            format_version: 1,
244            runtime_version: &self.runtime_version,
245            contract_hash: &self.contract_hash,
246            target,
247            pin,
248        };
249        Ok(hash(&serde_json::to_vec(&identity).map_err(|_| {
250            failure("could not encode runtime identity")
251        })?))
252    }
253    /// Verify and resolve an existing installation, without network access.
254    pub fn resolve(&self, cache: &Path, target: &str) -> Result<Installation> {
255        let directory = self.destination(cache, target)?;
256        let entry = self.target(target)?;
257        let expected = self.installation_identity(target)?;
258        let marker = directory.join(".complete");
259        let metadata = fs::symlink_metadata(&marker).map_err(io)?;
260        if !metadata.is_file()
261            || metadata.len() != 64
262            || fs::read(marker).map_err(io)? != expected.as_bytes()
263        {
264            return Err(failure(
265                "runtime installation is incomplete or belongs to another manifest",
266            ));
267        }
268        verify_inventory(&directory, &entry.files)?;
269        for (name, file) in &entry.files {
270            verify_parents(&directory, name)?;
271            digest_file(&directory.join(name), file)?;
272        }
273        Ok(Installation {
274            directory,
275            target: target.into(),
276            manifest: self.clone(),
277        })
278    }
279    /// Install a local archive with digests already pinned in this manifest.
280    /// The explicit target supports cross-provisioning without executing it.
281    pub fn install_archive(
282        &self,
283        cache: &Path,
284        target: &str,
285        archive: &Path,
286    ) -> Result<Installation> {
287        let entry = self.target(target)?;
288        let destination = self.destination(cache, target)?;
289        fs::create_dir_all(cache).map_err(io)?;
290        let lock = OpenOptions::new()
291            .read(true)
292            .write(true)
293            .create(true)
294            .truncate(false)
295            .open(cache.join(".install.lock"))
296            .map_err(io)?;
297        let until = Instant::now() + Duration::from_secs(60);
298        loop {
299            match lock.try_lock_exclusive() {
300                Ok(()) => break,
301                Err(error)
302                    if (error.kind() == std::io::ErrorKind::WouldBlock
303                        || error.raw_os_error() == fs2::lock_contended_error().raw_os_error())
304                        && Instant::now() < until =>
305                {
306                    std::thread::sleep(Duration::from_millis(20))
307                }
308                Err(_) => return Err(failure("could not acquire runtime installation lock")),
309            }
310        }
311        recover_staging(cache)?;
312        if destination.exists() {
313            return self.resolve(cache, target);
314        }
315        let mut source = File::open(archive).map_err(io)?;
316        if source.metadata().map_err(io)?.len() != entry.size {
317            return Err(failure("runtime archive size mismatch"));
318        }
319        // Keep the verified archive handle and stream to a private temporary
320        // file; never reopen an attacker-swappable archive path for extraction.
321        let mut staged_archive = tempfile::tempfile().map_err(io)?;
322        let mut digest = Sha256::new();
323        let mut total = 0u64;
324        let mut buffer = [0u8; 64 * 1024];
325        loop {
326            let count = source.read(&mut buffer).map_err(io)?;
327            if count == 0 {
328                break;
329            }
330            total += count as u64;
331            if total > entry.size {
332                return Err(failure("runtime archive exceeds size limit"));
333            }
334            digest.update(&buffer[..count]);
335            staged_archive.write_all(&buffer[..count]).map_err(io)?;
336        }
337        if total != entry.size || format!("{:x}", digest.finalize()) != entry.sha256 {
338            return Err(failure("runtime archive checksum mismatch"));
339        }
340        use std::io::Seek;
341        staged_archive.rewind().map_err(io)?;
342        let parent = destination
343            .parent()
344            .ok_or_else(|| failure("runtime destination is invalid"))?;
345        fs::create_dir_all(parent).map_err(io)?;
346        let stage = tempfile::Builder::new()
347            .prefix(STAGE_PREFIX)
348            .tempdir_in(cache)
349            .map_err(io)?;
350        let mut owner = File::create(stage.path().join(".stage-owner")).map_err(io)?;
351        owner.write_all(STAGE_OWNER).map_err(io)?;
352        owner.sync_all().map_err(io)?;
353        sync_directory(stage.path())?;
354        sync_directory(cache)?;
355        // USTAR has one 512-byte header and padded data blocks per file,
356        // two end blocks, then padding to GNU tar's 20-block record size.
357        let blocks: u64 = entry
358            .files
359            .values()
360            .map(|file| 512 + file.size.div_ceil(512) * 512)
361            .sum();
362        let expanded_limit = (blocks + 1024).div_ceil(10_240) * 10_240;
363        let decoder = flate2::read::MultiGzDecoder::new(staged_archive).take(expanded_limit + 1);
364        let mut tar = tar::Archive::new(decoder);
365        let mut seen = BTreeSet::new();
366        // Raw iteration prevents tar from allocating GNU/PAX extension bodies
367        // before the SDK has inspected their header and rejected them.
368        for item in tar.entries().map_err(io)?.raw(true) {
369            let mut item = item.map_err(io)?;
370            let name = item
371                .path()
372                .map_err(io)?
373                .to_str()
374                .ok_or_else(|| failure("runtime archive path must be UTF-8"))?
375                .to_owned();
376            if !safe_name(&name)
377                || !item.header().entry_type().is_file()
378                || !seen.insert(name.clone())
379            {
380                return Err(failure(
381                    "runtime archive contains an unsafe, duplicate or non-file entry",
382                ));
383            }
384            let expected = entry
385                .files
386                .get(&name)
387                .ok_or_else(|| failure("runtime archive contains an undeclared file"))?;
388            if item.size() != expected.size {
389                return Err(failure("runtime file size mismatch"));
390            }
391            let output = stage.path().join(&name);
392            fs::create_dir_all(output.parent().unwrap()).map_err(io)?;
393            let mut file = OpenOptions::new()
394                .write(true)
395                .create_new(true)
396                .open(&output)
397                .map_err(io)?;
398            std::io::copy(&mut item, &mut file).map_err(io)?;
399            file.sync_all().map_err(io)?;
400            #[cfg(unix)]
401            {
402                use std::os::unix::fs::PermissionsExt;
403                fs::set_permissions(
404                    &output,
405                    fs::Permissions::from_mode(if expected.executable { 0o755 } else { 0o644 }),
406                )
407                .map_err(io)?;
408            }
409            file.sync_all().map_err(io)?;
410            digest_file(&output, expected)?;
411        }
412        let mut remainder = tar.into_inner();
413        let mut padding = [0u8; 8192];
414        loop {
415            let count = remainder.read(&mut padding).map_err(io)?;
416            if count == 0 {
417                break;
418            }
419            if padding[..count].iter().any(|byte| *byte != 0) {
420                return Err(failure(
421                    "runtime archive contains data after its end marker",
422                ));
423            }
424        }
425        if remainder.limit() == 0 {
426            return Err(failure(
427                "runtime decompression exceeded the declared inventory bound",
428            ));
429        }
430        if seen.len() != entry.files.len() {
431            return Err(failure("runtime archive is missing required files"));
432        }
433        #[cfg(unix)]
434        {
435            let mut directories = std::collections::BTreeSet::new();
436            for name in entry.files.keys() {
437                let mut parent = stage.path().join(name);
438                parent.pop();
439                while parent.starts_with(stage.path()) {
440                    directories.insert(parent.clone());
441                    if !parent.pop() {
442                        break;
443                    }
444                }
445            }
446            for directory in directories.iter().rev() {
447                File::open(directory).map_err(io)?.sync_all().map_err(io)?;
448            }
449        }
450        let identity = self.installation_identity(target)?;
451        let mut marker = File::create(stage.path().join(".complete")).map_err(io)?;
452        marker.write_all(identity.as_bytes()).map_err(io)?;
453        marker.sync_all().map_err(io)?;
454        #[cfg(unix)]
455        File::open(stage.path())
456            .map_err(io)?
457            .sync_all()
458            .map_err(io)?;
459        fs::rename(stage.path(), &destination).map_err(io)?;
460        #[cfg(unix)]
461        File::open(destination.parent().unwrap())
462            .map_err(io)?
463            .sync_all()
464            .map_err(io)?;
465        #[cfg(unix)]
466        File::open(cache).map_err(io)?.sync_all().map_err(io)?;
467        self.resolve(cache, target)
468    }
469    /// Explicit, bounded HTTPS provisioning. Proxy settings follow ureq's
470    /// standard environment support. Document operations never call this.
471    pub fn install(&self, cache: &Path, target: &str) -> Result<Installation> {
472        let entry = self.target(target)?;
473        if !self.release_base_url.starts_with("https://")
474            || self.release_base_url.contains('?')
475            || self.release_base_url.contains('#')
476        {
477            return Err(failure(
478                "runtime release endpoint must be an immutable HTTPS URL",
479            ));
480        }
481        let url = format!(
482            "{}/{}",
483            self.release_base_url.trim_end_matches('/'),
484            entry.archive
485        );
486        let agent = ureq::Agent::config_builder()
487            .timeout_global(Some(Duration::from_secs(120)))
488            .max_redirects(5)
489            .build()
490            .new_agent();
491        let mut response = agent.get(&url).call().map_err(|_| {
492            failure("runtime download failed; provision the pinned archive offline if needed")
493        })?;
494        let mut staged = tempfile::NamedTempFile::new().map_err(io)?;
495        let copied = std::io::copy(
496            &mut response.body_mut().as_reader().take(entry.size + 1),
497            &mut staged,
498        )
499        .map_err(io)?;
500        if copied != entry.size {
501            return Err(failure(
502                "runtime download was truncated or exceeded its pinned size",
503            ));
504        }
505        staged.flush().map_err(io)?;
506        self.install_archive(cache, target, staged.path())
507    }
508}
509// The global cache lock is held. Only this versioned marker and namespace
510// identify a disposable staging directory; never remove unrelated cache data.
511fn recover_staging(cache: &Path) -> Result<()> {
512    for entry in fs::read_dir(cache).map_err(io)? {
513        let entry = entry.map_err(io)?;
514        if !entry
515            .file_name()
516            .to_string_lossy()
517            .starts_with(STAGE_PREFIX)
518            || !entry.file_type().map_err(io)?.is_dir()
519        {
520            continue;
521        }
522        let marker = entry.path().join(".stage-owner");
523        let Ok(metadata) = fs::symlink_metadata(&marker) else {
524            continue;
525        };
526        if metadata.is_file()
527            && metadata.len() == STAGE_OWNER.len() as u64
528            && fs::read(marker).map_err(io)? == STAGE_OWNER
529        {
530            fs::remove_dir_all(entry.path()).map_err(io)?;
531        }
532    }
533    Ok(())
534}
535fn verify_inventory(root: &Path, files: &BTreeMap<String, RuntimeFile>) -> Result<()> {
536    if !fs::symlink_metadata(root).map_err(io)?.is_dir() {
537        return Err(failure("runtime root must be a physical directory"));
538    }
539    let mut directories = BTreeSet::new();
540    for name in files.keys() {
541        let mut parent = Path::new(name).parent();
542        while let Some(path) = parent {
543            if path.as_os_str().is_empty() {
544                break;
545            }
546            directories.insert(path.to_path_buf());
547            parent = path.parent();
548        }
549    }
550    let mut pending = vec![PathBuf::new()];
551    while let Some(relative) = pending.pop() {
552        for entry in fs::read_dir(root.join(&relative)).map_err(io)? {
553            let entry = entry.map_err(io)?;
554            let path = relative.join(entry.file_name());
555            let kind = entry.file_type().map_err(io)?;
556            if kind.is_dir() && directories.contains(&path) {
557                pending.push(path);
558                continue;
559            }
560            let name = path
561                .to_str()
562                .ok_or_else(|| failure("installed runtime path must be UTF-8"))?
563                .replace('\\', "/");
564            if !kind.is_file()
565                || (!files.contains_key(&name)
566                    && !matches!(name.as_str(), ".complete" | ".stage-owner"))
567            {
568                return Err(failure(
569                    "installed runtime contains an undeclared file or directory",
570                ));
571            }
572            if name == ".stage-owner" && fs::read(entry.path()).map_err(io)? != STAGE_OWNER {
573                return Err(failure("installed runtime staging identity mismatch"));
574            }
575        }
576    }
577    Ok(())
578}
579fn verify_parents(root: &Path, name: &str) -> Result<()> {
580    let mut path = root.to_path_buf();
581    if !fs::symlink_metadata(&path).map_err(io)?.is_dir() {
582        return Err(failure("runtime root must be a physical directory"));
583    }
584    for component in name.split('/').take(name.split('/').count() - 1) {
585        path.push(component);
586        if !fs::symlink_metadata(&path).map_err(io)?.is_dir() {
587            return Err(failure("runtime parent must be a physical directory"));
588        }
589    }
590    Ok(())
591}
592impl Installation {
593    pub fn directory(&self) -> &Path {
594        &self.directory
595    }
596    pub fn runtime_version(&self) -> &str {
597        &self.manifest.runtime_version
598    }
599    pub fn target(&self) -> &str {
600        &self.target
601    }
602    pub fn host_options(&self, mode: HostMode) -> Result<HostOptions> {
603        if self.target != host_target()? {
604            return Err(Error::new(
605                "PLATFORM_UNSUPPORTED",
606                "installed runtime cannot execute on this build target",
607            ));
608        }
609        let target = self.manifest.target(&self.target)?;
610        for (name, file) in &target.files {
611            verify_parents(&self.directory, name)?;
612            digest_file(&self.directory.join(name), file)?;
613        }
614        let mut options = HostOptions::new(self.directory.join(if mode == HostMode::Cli {
615            &target.cli
616        } else {
617            &target.document_host
618        }));
619        options.mode = mode;
620        Ok(options)
621    }
622}
623
624#[cfg(test)]
625mod identity_tests {
626    use super::*;
627    #[test]
628    fn installation_identity_is_stable_across_serde_map_features() {
629        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();
630        assert_eq!(
631            manifest
632                .identity_for_pin("linux-x64", &manifest.targets["linux-x64"])
633                .unwrap(),
634            "0edc9ed2a0621331327f28c9d8b44a0759aee20fb1c5056f849d741bb4afd76e"
635        );
636    }
637}