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