1use 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}
112pub 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 pub fn pinned() -> Result<Self> {
129 Self::from_json(include_bytes!("../../assets/runtime-manifest.json"))
130 }
131 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 #[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 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 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 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 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 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 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}
509fn 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}