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