返回 CodeWhale
compiled_host.rs
根目录 / crates / cli / src / update / compiled_host.rs
1 //! Optional companion delivery through the existing updater's locked source.
2 //! This is installation provenance, never a runtime or extension authority.
3 use super::*;
4 use codewhale_tui::delivery_files::GuardedFile;
5 use std::collections::HashSet;
6
7 const CATALOG: &str = "codewhale-extension-hosts.json";
8 const RECEIPT: &str = "codewhale-extension-host.release.json";
9
10 #[derive(serde::Deserialize)]
11 struct Catalog {
12 schema: u64,
13 version: String,
14 source_sha: String,
15 bundle_sha256: String,
16 hosts: Vec<Host>,
17 }
18
19 #[derive(serde::Deserialize)]
20 struct Host {
21 target: String,
22 asset: String,
23 sha256: String,
24 notices_asset: String,
25 notices_sha256: String,
26 source_asset: String,
27 source_sha256: String,
28 runtime_version: String,
29 runtime_revision: String,
30 runtime_sha256: String,
31 webkit_revision: String,
32 bundle_sha256: String,
33 source_commit: String,
34 native_platform: String,
35 native_arch: String,
36 libc: String,
37 passed: u64,
38 failed: u64,
39 skipped: u64,
40 test_log_sha256: String,
41 native_passed: u64,
42 native_failed: u64,
43 native_skipped: u64,
44 native_log_sha256: String,
45 license_closure: String,
46 relink_source: String,
47 }
48
49 fn hex(value: &str, length: usize) -> bool {
50 value.len() == length
51 && value
52 .bytes()
53 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
54 }
55
56 fn version(value: &str) -> bool {
57 let (core, suffix) = value
58 .split_once('-')
59 .map_or((value, None), |(a, b)| (a, Some(b)));
60 let parts = core.split('.').collect::<Vec<_>>();
61 parts.len() == 3
62 && parts
63 .iter()
64 .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
65 && suffix.is_none_or(|s| {
66 !s.is_empty()
67 && s.bytes()
68 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
69 })
70 }
71
72 fn native_target(target: &str) -> Option<(&'static str, &'static str)> {
73 Some(match target {
74 "linux-x64" => ("linux", "x64"),
75 "linux-arm64" => ("linux", "arm64"),
76 "macos-x64" => ("darwin", "x64"),
77 "macos-arm64" => ("darwin", "arm64"),
78 "windows-x64" => ("win32", "x64"),
79 "windows-arm64" => ("win32", "arm64"),
80 _ => return None,
81 })
82 }
83
84 fn parse(bytes: &[u8], expected_version: &str) -> Result<Catalog> {
85 if bytes.len() > 64 * 1024 {
86 bail!("compiled host catalog exceeds 64 KiB");
87 }
88 let catalog: Catalog =
89 serde_json::from_slice(bytes).context("malformed compiled host catalog")?;
90 if catalog.schema != 1
91 || !version(&catalog.version)
92 || catalog.version != expected_version.trim_start_matches('v')
93 || !hex(&catalog.source_sha, 40)
94 || !hex(&catalog.bundle_sha256, 64)
95 || catalog.hosts.len() > 6
96 {
97 bail!("compiled host catalog does not identify this exact release");
98 }
99 let mut targets = HashSet::new();
100 for host in &catalog.hosts {
101 let (platform, arch) = native_target(&host.target)
102 .context("unsupported compiled host target; Android is not a qualified Codewhale delivery target")?;
103 let minimum_native = if platform == "win32" { 8 } else { 1 };
104 let stem = format!("codewhale-extension-host-{}", host.target);
105 let binary = format!("{stem}{}", if platform == "win32" { ".exe" } else { "" });
106 if !targets.insert(&host.target)
107 || host.asset != binary
108 || host.notices_asset != format!("{stem}-LICENSES.txt")
109 || host.source_asset != format!("{stem}-relink-source.tar.gz")
110 || [
111 &host.sha256,
112 &host.notices_sha256,
113 &host.source_sha256,
114 &host.runtime_sha256,
115 &host.test_log_sha256,
116 &host.native_log_sha256,
117 ]
118 .iter()
119 .any(|digest| !hex(digest, 64))
120 || !version(&host.runtime_version)
121 || !hex(&host.runtime_revision, 40)
122 || !hex(&host.webkit_revision, 40)
123 || host.bundle_sha256 != catalog.bundle_sha256
124 || host.source_commit != catalog.source_sha
125 || host.native_platform != platform
126 || host.native_arch != arch
127 || !(6..=9_007_199_254_740_991).contains(&host.passed)
128 || host.failed != 0
129 || host.skipped != 0
130 || !(minimum_native..=9_007_199_254_740_991).contains(&host.native_passed)
131 || host.native_failed != 0
132 || host.native_skipped != 0
133 || host.license_closure != "complete"
134 || host.relink_source != "complete"
135 || if platform == "linux" {
136 !matches!(host.libc.as_str(), "glibc" | "musl")
137 } else {
138 host.libc != "none"
139 }
140 {
141 bail!("compiled host is not a complete matching-native qualified payload");
142 }
143 }
144 Ok(catalog)
145 }
146
147 struct Replacement {
148 name: String,
149 staged: GuardedFile,
150 old: Option<GuardedFile>,
151 backup_name: String,
152 retired: bool,
153 published: bool,
154 }
155
156 pub(super) struct Prepared {
157 directory: PathBuf,
158 replacements: Vec<Replacement>,
159 }
160
161 impl Prepared {
162 fn verify_target(directory: &Path, replacement: &mut Replacement) -> Result<()> {
163 if let Some(old) = &mut replacement.old {
164 old.verify()?;
165 } else if GuardedFile::open(directory, &replacement.name)?.is_some() {
166 bail!(
167 "compiled host destination appeared: {}",
168 directory.join(&replacement.name).display()
169 );
170 }
171 replacement.staged.verify()?;
172 Ok(())
173 }
174
175 pub(super) fn recovery_paths(&self) -> Vec<PathBuf> {
176 self.replacements
177 .iter()
178 .filter(|replacement| replacement.retired)
179 .map(|replacement| self.directory.join(&replacement.backup_name))
180 .collect()
181 }
182
183 pub(super) fn publish(&mut self) -> Result<()> {
184 for replacement in &mut self.replacements {
185 Self::verify_target(&self.directory, replacement)?;
186 }
187 for replacement in &mut self.replacements {
188 Self::verify_target(&self.directory, replacement)?;
189 if let Some(old) = &mut replacement.old {
190 let result = old.move_to_vacant(&replacement.backup_name);
191 // A Unix name can change between comparison and rename. Record
192 // the move even when post-move identity validation rejects it.
193 replacement.retired = old.name() == replacement.backup_name;
194 result.with_context(|| {
195 format!(
196 "failed to retire {}; recovery at {}",
197 replacement.name,
198 self.directory.join(&replacement.backup_name).display()
199 )
200 })?;
201 }
202 let result = replacement.staged.move_to_vacant(&replacement.name);
203 replacement.published = replacement.staged.name() == replacement.name;
204 result.with_context(|| {
205 format!(
206 "failed to publish {}; no concurrent destination was overwritten",
207 replacement.name
208 )
209 })?;
210 }
211 Ok(())
212 }
213
214 pub(super) fn rollback(&mut self) -> Result<()> {
215 let mut failures = Vec::new();
216 for replacement in self.replacements.iter_mut().rev() {
217 let restored = (|| {
218 if replacement.published {
219 // The retained staged handle, not equal bytes, proves this
220 // entry is the one we published. Preserve changed entries.
221 replacement.staged.verify()?;
222 let recovery = GuardedFile::recovery_name();
223 let result = replacement.staged.move_to_vacant(&recovery);
224 replacement.published = replacement.staged.name() == replacement.name;
225 result?;
226 }
227 if replacement.retired {
228 // Restore the entry actually moved, even if a racing Unix
229 // replacement made retirement refuse its identity. A fresh
230 // protected handle retains that recovery object's identity.
231 let original = replacement.old.as_mut().context("retired handle missing")?;
232 if original.verify().is_ok() {
233 original.move_to_vacant(&replacement.name)?;
234 } else {
235 // On Unix the name moved may be a racing replacement;
236 // on either platform an observed writer may have changed
237 // the recovery bytes. Capture that actual recovery entry
238 // and restore it only if the old destination is vacant.
239 let mut recovery =
240 GuardedFile::open(&self.directory, &replacement.backup_name)?
241 .context("retired entry disappeared")?;
242 recovery.move_to_vacant(&replacement.name)?;
243 }
244 replacement.retired = false;
245 }
246 Ok::<_, anyhow::Error>(())
247 })();
248 if let Err(error) = restored {
249 failures.push(format!(
250 "{}: {} (retired entry {}; staged/published entry {})",
251 replacement.name,
252 error,
253 self.directory.join(&replacement.backup_name).display(),
254 self.directory.join(replacement.staged.name()).display()
255 ));
256 }
257 }
258 if !failures.is_empty() {
259 bail!(
260 "compiled host rollback needs attention; recovery bytes preserved: {}",
261 failures.join("; ")
262 );
263 }
264 Ok(())
265 }
266 }
267
268 pub(super) fn required_for(current_exe: &Path) -> Result<bool> {
269 let directory = current_exe
270 .parent()
271 .context("updater executable has no parent")?;
272 Ok(
273 std::env::var("CODEWHALE_INSTALL_COMPILED_HOST").as_deref() == Ok("1")
274 || GuardedFile::open_bounded(directory, RECEIPT, 64 * 1024)?.is_some(),
275 )
276 }
277
278 pub(super) fn require_catalog_manifest(bytes: &[u8]) -> Result<()> {
279 let text = std::str::from_utf8(bytes).context("compiled host manifest is not UTF-8")?;
280 if !parse_checksum_manifest(text)?.contains_key(CATALOG) {
281 bail!("release source has no qualified compiled-host catalog");
282 }
283 Ok(())
284 }
285
286 pub(super) fn prepare(
287 download: &DownloadPlan,
288 release_version: &str,
289 target: &str,
290 current_exe: &Path,
291 proxy: Option<&Proxy>,
292 ) -> Result<Option<Prepared>> {
293 let directory = current_exe
294 .parent()
295 .context("updater executable has no parent")?;
296 let host_path = directory.join(format!(
297 "codewhale-extension-host{}",
298 std::env::consts::EXE_SUFFIX
299 ));
300 let receipt_path = directory.join(RECEIPT);
301 let mut receipt_file = GuardedFile::open_bounded(directory, RECEIPT, 64 * 1024)?;
302 let requested = std::env::var("CODEWHALE_INSTALL_COMPILED_HOST").as_deref() == Ok("1");
303 if receipt_file.is_none() && !requested {
304 return Ok(None);
305 }
306 let mut ownership = HashMap::new();
307 if let Some(mut receipt) = receipt_file.take() {
308 let old = parse(&receipt.read_bounded(64 * 1024)?, env!("CARGO_PKG_VERSION"))?;
309 let entry = old
310 .hosts
311 .iter()
312 .find(|host| host.target == target)
313 .context("installed host receipt has no matching target")?;
314 for (name, expected) in [
315 (
316 host_path
317 .file_name()
318 .and_then(|name| name.to_str())
319 .context("invalid host basename")?,
320 entry.sha256.as_str(),
321 ),
322 (
323 "codewhale-extension-host.LICENSES.txt",
324 entry.notices_sha256.as_str(),
325 ),
326 (
327 "codewhale-extension-host.relink-source.tar.gz",
328 entry.source_sha256.as_str(),
329 ),
330 ] {
331 let file = GuardedFile::open(directory, name)?
332 .with_context(|| format!("owned compiled host file missing: {name}"))?;
333 if file.sha256() != expected {
334 bail!("compiled host ownership changed: {name}");
335 }
336 ownership.insert(directory.join(name), file);
337 }
338 ownership.insert(receipt_path.clone(), receipt);
339 } else if GuardedFile::open(
340 directory,
341 host_path
342 .file_name()
343 .and_then(|name| name.to_str())
344 .context("invalid host basename")?,
345 )?
346 .is_some()
347 {
348 bail!(
349 "compiled host beside this CLI has no installation receipt; it was not replaced. Install into a fresh prefix or use Node"
350 );
351 }
352 let fetch = |name: &str| -> Result<Vec<u8>> {
353 let url = reqwest::Url::parse(&download.binary_url)?.join(name)?;
354 let bytes = download_url(url.as_str(), proxy)?;
355 verify_manifest_asset(download, name, &bytes)?;
356 Ok(bytes)
357 };
358 if !download.checksums.contains_key(CATALOG) {
359 bail!(
360 "compiled host requested/installed but the selected release source has no qualified catalog; no files were changed. Node remains available"
361 );
362 }
363 let catalog_bytes = fetch(CATALOG)?;
364 let catalog = parse(&catalog_bytes, release_version)?;
365 let host = catalog
366 .hosts
367 .iter()
368 .find(|host| host.target == target)
369 .context("release has no qualified compiled image for this target; use Node")?;
370 let payloads = [
371 (host.asset.as_str(), host_path, host.sha256.as_str(), true),
372 (
373 host.notices_asset.as_str(),
374 directory.join("codewhale-extension-host.LICENSES.txt"),
375 host.notices_sha256.as_str(),
376 false,
377 ),
378 (
379 host.source_asset.as_str(),
380 directory.join("codewhale-extension-host.relink-source.tar.gz"),
381 host.source_sha256.as_str(),
382 false,
383 ),
384 (
385 CATALOG,
386 receipt_path,
387 download
388 .checksums
389 .get(CATALOG)
390 .context("catalog checksum missing")?
391 .as_str(),
392 false,
393 ),
394 ];
395 let mut replacements = Vec::new();
396 for (name, path, expected, executable) in payloads {
397 let bytes = if name == CATALOG {
398 catalog_bytes.clone()
399 } else {
400 fetch(name)?
401 };
402 if sha256_hex(&bytes) != expected {
403 bail!("compiled host catalog and manifest disagree for {name}");
404 }
405 if executable && cfg!(target_os = "linux") {
406 let required = highest_required_glibc(&bytes);
407 let available = detect_host_glibc();
408 if (host.libc == "glibc" && available.is_none())
409 || required
410 .is_some_and(|required| available.is_none_or(|available| available < required))
411 || (host.libc == "musl" && required.is_some())
412 {
413 bail!(
414 "optional compiled Bun image has an incompatible libc floor; the CLI remains static musl. Use Node"
415 );
416 }
417 }
418 let old = ownership.remove(&path);
419 let filename = path
420 .file_name()
421 .and_then(|name| name.to_str())
422 .context("invalid compiled payload basename")?
423 .to_owned();
424 if old.is_none() && GuardedFile::open(directory, &filename)?.is_some() {
425 bail!("refusing unowned compiled host file {}", path.display());
426 }
427 let staged = GuardedFile::stage(directory, &bytes, executable)?;
428 if staged.sha256() != expected {
429 bail!("compiled host stage changed: {}", staged.name());
430 }
431 replacements.push(Replacement {
432 name: filename,
433 staged,
434 old,
435 backup_name: GuardedFile::recovery_name(),
436 retired: false,
437 published: false,
438 });
439 }
440 Ok(Some(Prepared {
441 directory: directory.to_path_buf(),
442 replacements,
443 }))
444 }
445
446 #[cfg(test)]
447 mod tests {
448 use super::*;
449
450 fn receipt() -> serde_json::Value {
451 serde_json::json!({
452 "schema":1,"version":"0.10.1","source_sha":"b".repeat(40),"bundle_sha256":"a".repeat(64),
453 "hosts":[{
454 "target":"linux-x64","asset":"codewhale-extension-host-linux-x64","sha256":"c".repeat(64),
455 "notices_asset":"codewhale-extension-host-linux-x64-LICENSES.txt","notices_sha256":"d".repeat(64),
456 "source_asset":"codewhale-extension-host-linux-x64-relink-source.tar.gz","source_sha256":"e".repeat(64),
457 "runtime_version":"1.4.0","runtime_revision":"f".repeat(40),"runtime_sha256":"1".repeat(64),"webkit_revision":"2".repeat(40),
458 "bundle_sha256":"a".repeat(64),"source_commit":"b".repeat(40),"native_platform":"linux","native_arch":"x64","libc":"glibc",
459 "passed":6,"failed":0,"skipped":0,"test_log_sha256":"3".repeat(64),"native_passed":1,"native_failed":0,"native_skipped":0,"native_log_sha256":"4".repeat(64),"license_closure":"complete","relink_source":"complete"
460 }]
461 })
462 }
463
464 #[test]
465 fn catalog_refuses_cross_native_or_skipped_containment_and_mixed_release() {
466 let valid = receipt();
467 assert!(parse(&serde_json::to_vec(&valid).unwrap(), "v0.10.1").is_ok());
468 for (key, value) in [
469 ("native_skipped", serde_json::json!(1)),
470 ("native_arch", serde_json::json!("arm64")),
471 ("native_passed", serde_json::json!(0)),
472 ("source_asset", serde_json::json!("../source.tar.gz")),
473 ("relink_source", serde_json::json!("pending")),
474 ] {
475 let mut invalid = valid.clone();
476 invalid["hosts"][0][key] = value;
477 assert!(
478 parse(&serde_json::to_vec(&invalid).unwrap(), "0.10.1").is_err(),
479 "{key}"
480 );
481 }
482 assert!(parse(&serde_json::to_vec(&valid).unwrap(), "0.10.2").is_err());
483 }
484
485 #[test]
486 fn catalog_requires_all_eight_current_windows_native_cases() {
487 let mut value = receipt();
488 let host = &mut value["hosts"][0];
489 for (key, field) in [
490 ("target", "windows-x64"),
491 ("asset", "codewhale-extension-host-windows-x64.exe"),
492 (
493 "notices_asset",
494 "codewhale-extension-host-windows-x64-LICENSES.txt",
495 ),
496 (
497 "source_asset",
498 "codewhale-extension-host-windows-x64-relink-source.tar.gz",
499 ),
500 ("native_platform", "win32"),
501 ("libc", "none"),
502 ] {
503 host[key] = serde_json::json!(field);
504 }
505 host["native_passed"] = serde_json::json!(7);
506 assert!(parse(&serde_json::to_vec(&value).unwrap(), "0.10.1").is_err());
507 value["hosts"][0]["native_passed"] = serde_json::json!(8);
508 assert!(parse(&serde_json::to_vec(&value).unwrap(), "0.10.1").is_ok());
509 }
510
511 fn prepared(directory: &Path, name: &str, old: Option<&[u8]>) -> Prepared {
512 if let Some(bytes) = old {
513 std::fs::write(directory.join(name), bytes).unwrap();
514 }
515 Prepared {
516 directory: directory.to_path_buf(),
517 replacements: vec![Replacement {
518 name: name.into(),
519 staged: GuardedFile::stage(directory, b"qualified", true).unwrap(),
520 old: GuardedFile::open(directory, name).unwrap(),
521 backup_name: GuardedFile::recovery_name(),
522 retired: false,
523 published: false,
524 }],
525 }
526 }
527
528 #[test]
529 fn publish_retires_no_companion_if_a_destination_changed() {
530 let directory = tempfile::tempdir().unwrap();
531 let mut prepared = prepared(directory.path(), "host", None);
532 std::fs::write(directory.path().join("host"), b"foreign").unwrap();
533 assert!(prepared.publish().is_err());
534 assert_eq!(
535 std::fs::read(directory.path().join("host")).unwrap(),
536 b"foreign"
537 );
538 }
539
540 #[cfg(unix)]
541 #[test]
542 fn same_byte_inode_swap_cannot_establish_ownership() {
543 let directory = tempfile::tempdir().unwrap();
544 let mut prepared = prepared(directory.path(), "host", Some(b"old"));
545 std::fs::rename(
546 directory.path().join("host"),
547 directory.path().join("original"),
548 )
549 .unwrap();
550 std::fs::write(directory.path().join("host"), b"old").unwrap();
551 assert!(prepared.publish().is_err());
552 assert_eq!(
553 std::fs::read(directory.path().join("host")).unwrap(),
554 b"old"
555 );
556 assert_eq!(
557 std::fs::read(directory.path().join("original")).unwrap(),
558 b"old"
559 );
560 }
561
562 #[cfg(unix)]
563 #[test]
564 fn changed_writer_refuses_retirement_and_preserves_original() {
565 let directory = tempfile::tempdir().unwrap();
566 let mut prepared = prepared(directory.path(), "host", Some(b"old"));
567 std::fs::write(directory.path().join("host"), b"concurrent").unwrap();
568 assert!(prepared.publish().is_err());
569 assert_eq!(
570 std::fs::read(directory.path().join("host")).unwrap(),
571 b"concurrent"
572 );
573 }
574
575 #[test]
576 fn successful_publication_and_rollback_preserve_both_versions() {
577 let directory = tempfile::tempdir().unwrap();
578 let mut prepared = prepared(directory.path(), "host", Some(b"old"));
579 prepared.publish().unwrap();
580 assert_eq!(
581 std::fs::read(directory.path().join("host")).unwrap(),
582 b"qualified"
583 );
584 prepared.rollback().unwrap();
585 assert_eq!(
586 std::fs::read(directory.path().join("host")).unwrap(),
587 b"old"
588 );
589 let recovered = directory
590 .path()
591 .join(prepared.replacements[0].staged.name());
592 assert_eq!(std::fs::read(recovered).unwrap(), b"qualified");
593 }
594
595 #[cfg(unix)]
596 #[test]
597 fn partial_rollback_preserves_changed_published_entry_and_recovers_other_slot() {
598 let directory = tempfile::tempdir().unwrap();
599 let mut first = prepared(directory.path(), "host", Some(b"old-host"));
600 let mut second = prepared(directory.path(), "notices", Some(b"old-notices"));
601 first.replacements.append(&mut second.replacements);
602 first.publish().unwrap();
603 let backup = directory.path().join(&first.replacements[0].backup_name);
604 std::fs::rename(
605 directory.path().join("host"),
606 directory.path().join("published-original"),
607 )
608 .unwrap();
609 // Same bytes must still fail identity; a digest-only rollback deletes it.
610 std::fs::write(directory.path().join("host"), b"qualified").unwrap();
611 let error = first.rollback().unwrap_err().to_string();
612 assert!(error.contains("recovery bytes preserved"));
613 assert_eq!(
614 std::fs::read(directory.path().join("host")).unwrap(),
615 b"qualified"
616 );
617 assert_eq!(std::fs::read(backup).unwrap(), b"old-host");
618 assert_eq!(
619 std::fs::read(directory.path().join("notices")).unwrap(),
620 b"old-notices"
621 );
622 }
623 }
624
624 lines RUST