1use std::collections::BTreeSet;
4use std::env;
5use std::ffi::OsStr;
6use std::fs::{self, File};
7use std::io::{self, BufReader, IsTerminal as _, Write as _};
8use std::path::{Component, Path, PathBuf};
9use std::process::Command;
10
11use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use url::Url;
15use zip::ZipArchive;
16
17use tuffite_abi::{ABI_MAJOR, ABI_MINOR, ABI_PATCH};
18
19pub const MANIFEST_FILE: &str = "tuffite-framework.json";
20const GITHUB_API_VERSION: &str = "2022-11-28";
21
22pub const FRAMEWORK_ENV: &str = "TUFFITE_FRAMEWORK_DIR";
24pub const DIR_ENV: &str = "TUFFITE_FRAMEWORK_DIST";
26pub const CACHE_ENV: &str = "TUFFITE_FRAMEWORK_DIST_CACHE";
28pub const DOWNLOAD_DIR_ENV: &str = "TUFFITE_FRAMEWORK_DIST_DOWNLOAD_DIR";
30pub const TARGET_ENV: &str = "TUFFITE_FRAMEWORK_DIST_TARGET";
32pub const AUTO_DOWNLOAD_ENV: &str = "TUFFITE_FRAMEWORK_DIST_AUTO_DOWNLOAD";
34pub const PROGRESS_ENV: &str = "TUFFITE_FRAMEWORK_DIST_PROGRESS";
36pub const REPOSITORY_ENV: &str = "TUFFITE_GITHUB_REPOSITORY";
37pub const RELEASE_TAG_ENV: &str = "TUFFITE_FRAMEWORK_DIST_RELEASE_TAG";
38pub const ENV_VARS: &[&str] = &[
40 DIR_ENV,
41 FRAMEWORK_ENV,
42 CACHE_ENV,
43 DOWNLOAD_DIR_ENV,
44 TARGET_ENV,
45 AUTO_DOWNLOAD_ENV,
46 PROGRESS_ENV,
47 REPOSITORY_ENV,
48 RELEASE_TAG_ENV,
49];
50
51#[derive(Clone, Debug, Deserialize, Serialize)]
52pub struct FileEntry {
53 pub path: String,
54 pub size: u64,
55 pub sha256: String,
56}
57
58#[derive(Clone, Debug, Deserialize, Serialize)]
59pub struct Manifest {
60 pub framework_version: String,
61 pub chromium_revision: String,
62 pub target: String,
63 pub profile: String,
64 #[serde(default = "default_variant")]
65 pub variant: String,
66 pub component_build: bool,
67 #[serde(default)]
68 pub development_enabled: bool,
69 pub features: Vec<String>,
70 pub ffi_abi_major: u16,
71 pub ffi_abi_minor: u16,
72 pub ffi_abi_patch: u16,
74 pub link_directory: String,
75 pub runtime_directory: String,
76 pub bootstrap: FileEntry,
77 pub runtime_files: Vec<FileEntry>,
78}
79
80fn default_variant() -> String {
81 "full".to_owned()
82}
83
84#[derive(Clone, Debug)]
85pub struct Distribution {
86 root: PathBuf,
87 manifest: Manifest,
88}
89
90impl Distribution {
91 pub fn open(path: impl AsRef<Path>) -> Result<Self, String> {
92 let root = fs::canonicalize(path.as_ref()).map_err(|error| {
93 format!(
94 "resolve Tuffite Framework distribution {}: {error}",
95 path.as_ref().display()
96 )
97 })?;
98 if !root.is_dir() {
99 return Err(format!(
100 "Tuffite Framework distribution is not a directory: {}",
101 root.display()
102 ));
103 }
104 let manifest_path = root.join(MANIFEST_FILE);
105 let manifest: Manifest = serde_json::from_reader(BufReader::new(
106 File::open(&manifest_path)
107 .map_err(|error| format!("open {}: {error}", manifest_path.display()))?,
108 ))
109 .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?;
110 let distribution = Self { root, manifest };
111 distribution.validate_identity()?;
112 distribution.validate_layout()?;
113 Ok(distribution)
114 }
115
116 #[must_use]
117 pub fn root(&self) -> &Path {
118 &self.root
119 }
120
121 #[must_use]
122 pub const fn manifest(&self) -> &Manifest {
123 &self.manifest
124 }
125
126 pub fn runtime_root(&self) -> Result<PathBuf, String> {
127 safe_join(
128 &self.root,
129 &self.manifest.runtime_directory,
130 "runtime_directory",
131 )
132 }
133
134 pub fn runtime_files(&self, verify_hashes: bool) -> Result<Vec<(PathBuf, PathBuf)>, String> {
135 let runtime_root = self.runtime_root()?;
136 let mut files = Vec::with_capacity(self.manifest.runtime_files.len());
137 for entry in &self.manifest.runtime_files {
138 let relative = safe_relative(&entry.path, "runtime file")?;
139 let source = runtime_root.join(&relative);
140 let metadata = source.metadata().map_err(|error| {
141 format!(
142 "inspect Framework runtime file {}: {error}",
143 source.display()
144 )
145 })?;
146 if !metadata.is_file() || metadata.len() != entry.size {
147 return Err(format!(
148 "Framework runtime file size mismatch: {}",
149 source.display()
150 ));
151 }
152 if verify_hashes && hash_file(&source)? != entry.sha256 {
153 return Err(format!(
154 "Framework runtime file hash mismatch: {}",
155 source.display()
156 ));
157 }
158 files.push((source, relative));
159 }
160 Ok(files)
161 }
162
163 pub fn bootstrap(&self, verify_hash: bool) -> Result<PathBuf, String> {
164 let relative = safe_relative(&self.manifest.bootstrap.path, "bootstrap")?;
165 let source = self.root.join(relative);
166 let metadata = source.metadata().map_err(|error| {
167 format!("inspect Framework bootstrap {}: {error}", source.display())
168 })?;
169 if !metadata.is_file() || metadata.len() != self.manifest.bootstrap.size {
170 return Err(format!(
171 "Framework bootstrap size mismatch: {}",
172 source.display()
173 ));
174 }
175 if verify_hash && hash_file(&source)? != self.manifest.bootstrap.sha256 {
176 return Err(format!(
177 "Framework bootstrap hash mismatch: {}",
178 source.display()
179 ));
180 }
181 Ok(source)
182 }
183
184 fn validate_identity(&self) -> Result<(), String> {
185 if self.manifest.framework_version != env!("CARGO_PKG_VERSION") {
186 return Err(format!(
187 "incompatible Tuffite Framework Distribution at {}: the Distribution was built for Tuffite {}, but the current tuffite CLI is {}. Download the matching Release asset or rebuild it with `python3 x.py package --destination \"{}\"`",
188 self.root.display(),
189 self.manifest.framework_version,
190 env!("CARGO_PKG_VERSION"),
191 self.root.display(),
192 ));
193 }
194 if !matches!(self.manifest.variant.as_str(), "full" | "desktop") {
195 return Err(format!(
196 "unsupported Tuffite Framework variant {}",
197 self.manifest.variant
198 ));
199 }
200 if !tuffite_abi::is_compatible(
201 (
202 self.manifest.ffi_abi_major,
203 self.manifest.ffi_abi_minor,
204 self.manifest.ffi_abi_patch,
205 ),
206 (ABI_MAJOR, ABI_MINOR, ABI_PATCH),
207 ) {
208 return Err(format!(
209 "Tuffite Framework FFI ABI {}.{}.{} is incompatible with {}.{}.{}",
210 self.manifest.ffi_abi_major,
211 self.manifest.ffi_abi_minor,
212 self.manifest.ffi_abi_patch,
213 ABI_MAJOR,
214 ABI_MINOR,
215 ABI_PATCH
216 ));
217 }
218 Ok(())
219 }
220
221 fn validate_target(&self) -> Result<(), String> {
222 if let Ok(target) = env::var("TARGET")
223 && target != self.manifest.target
224 {
225 return Err(format!(
226 "Tuffite Framework target {} does not match Cargo target {target}",
227 self.manifest.target
228 ));
229 }
230 Ok(())
231 }
232
233 fn validate_layout(&self) -> Result<(), String> {
234 self.validate_target()?;
235 let link = safe_join(&self.root, &self.manifest.link_directory, "link_directory")?;
236 let runtime = self.runtime_root()?;
237 if !link.is_dir() || !runtime.is_dir() {
238 return Err(format!(
239 "Tuffite Framework distribution is incomplete: {}",
240 self.root.display()
241 ));
242 }
243 self.bootstrap(false)?;
244 if self.manifest.runtime_files.is_empty() {
245 return Err("Tuffite Framework runtime file list is empty".to_owned());
246 }
247 for path in [
248 "licenses/LICENSE.tuffite.txt",
249 "licenses/LICENSE.chromium.txt",
250 "licenses/THIRD_PARTY_NOTICES.chromium.txt",
251 ] {
252 if !self
253 .manifest
254 .runtime_files
255 .iter()
256 .any(|file| file.path == path && file.size > 0)
257 {
258 return Err(format!(
259 "Framework Distribution at {} is missing notice {path}; repackage this directory with `python3 x.py package --destination \"{}\"`, or update TUFFITE_FRAMEWORK_DIST",
260 self.root.display(),
261 self.root.display()
262 ));
263 }
264 }
265 let mut paths = BTreeSet::new();
266 for file in &self.manifest.runtime_files {
267 let path = safe_relative(&file.path, "runtime file")?;
268 if !paths.insert(path) {
269 return Err(format!("duplicate Framework runtime file: {}", file.path));
270 }
271 if file.sha256.len() != 64 || !file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
272 {
273 return Err(format!("invalid Framework runtime hash: {}", file.path));
274 }
275 }
276 Ok(())
277 }
278}
279
280fn local_distribution(
281 framework: &Path,
282 profile: &str,
283 targets: &[String],
284) -> Result<Option<Distribution>, String> {
285 let suffix = if profile == "release" {
286 String::new()
287 } else {
288 format!("-{profile}")
289 };
290 for target in targets {
291 let cpu = match target.split('-').next() {
292 Some("x86_64") => "x64",
293 Some("aarch64") => "arm64",
294 _ => continue,
295 };
296 let platform = if target.contains("apple-darwin") {
297 "macos"
298 } else if target.contains("windows") {
299 "windows"
300 } else {
301 "linux"
302 };
303 let candidates = [
305 framework
306 .join("dist")
307 .join(format!("tuffite-framework{suffix}-{platform}-{cpu}")),
308 framework
309 .join("dist")
310 .join(format!("tuffite-framework{suffix}-{cpu}")),
311 framework.join("dist/tuffite-framework"),
312 ];
313 for path in candidates {
314 if !path.exists() {
315 continue;
316 }
317 let distribution = Distribution::open(&path)?;
318 if distribution.manifest().target == *target
319 && distribution.manifest().profile == profile
320 {
321 return Ok(Some(distribution));
322 }
323 }
324 }
325 Ok(None)
326}
327
328#[derive(Clone, Debug)]
331pub struct Resolver {
332 distribution: Option<PathBuf>,
333 framework_directory: Option<PathBuf>,
334 cache_root: Option<PathBuf>,
335 download_directory: Option<PathBuf>,
336 target: Option<String>,
337 repository: Option<String>,
338 release_tag: Option<String>,
339 auto_download: bool,
340 force_download: bool,
341 progress: bool,
342}
343
344impl Default for Resolver {
345 fn default() -> Self {
346 Self::new()
347 }
348}
349
350impl Resolver {
351 #[must_use]
352 pub const fn new() -> Self {
353 Self {
354 distribution: None,
355 framework_directory: None,
356 cache_root: None,
357 download_directory: None,
358 target: None,
359 repository: None,
360 release_tag: None,
361 auto_download: true,
362 force_download: false,
363 progress: true,
364 }
365 }
366
367 pub fn from_env() -> Result<Self, String> {
369 let mut resolver = Self::new();
370 resolver.distribution = env::var_os(DIR_ENV).map(PathBuf::from);
371 resolver.framework_directory = env::var_os(FRAMEWORK_ENV)
372 .filter(|value| !value.is_empty())
373 .map(PathBuf::from);
374 resolver.cache_root = env::var_os(CACHE_ENV).map(PathBuf::from);
375 resolver.download_directory = env::var_os(DOWNLOAD_DIR_ENV).map(PathBuf::from);
376 resolver.target = env::var(TARGET_ENV)
377 .ok()
378 .filter(|value| !value.is_empty())
379 .or_else(|| env::var("TARGET").ok().filter(|value| !value.is_empty()));
380 resolver.repository = env::var(REPOSITORY_ENV)
381 .ok()
382 .filter(|value| !value.is_empty());
383 resolver.release_tag = env::var(RELEASE_TAG_ENV)
384 .ok()
385 .filter(|value| !value.is_empty());
386 if let Ok(value) = env::var(AUTO_DOWNLOAD_ENV) {
387 resolver.auto_download = parse_env_bool(AUTO_DOWNLOAD_ENV, &value)?;
388 }
389 resolver.progress = progress_enabled()?;
390 Ok(resolver)
391 }
392
393 #[must_use]
395 pub fn distribution(mut self, path: impl Into<PathBuf>) -> Self {
396 self.distribution = Some(path.into());
397 self
398 }
399
400 #[must_use]
403 pub fn managed(mut self) -> Self {
404 self.distribution = None;
405 self.framework_directory = None;
406 self
407 }
408
409 #[must_use]
411 pub fn framework_directory(mut self, directory: impl Into<PathBuf>) -> Self {
412 self.framework_directory = Some(directory.into());
413 self
414 }
415
416 #[must_use]
417 pub fn cache_root(mut self, path: impl Into<PathBuf>) -> Self {
418 self.cache_root = Some(path.into());
419 self
420 }
421
422 #[must_use]
424 pub fn download_directory(mut self, path: impl Into<PathBuf>) -> Self {
425 self.download_directory = Some(path.into());
426 self
427 }
428
429 #[must_use]
430 pub fn target(mut self, target: impl Into<String>) -> Self {
431 self.target = Some(target.into());
432 self
433 }
434
435 #[must_use]
438 pub fn repository(mut self, repository: impl Into<String>) -> Self {
439 self.repository = Some(repository.into());
440 self
441 }
442
443 #[must_use]
445 pub fn release_tag(mut self, tag: impl Into<String>) -> Self {
446 self.release_tag = Some(tag.into());
447 self
448 }
449
450 #[must_use]
452 pub const fn auto_download(mut self, enabled: bool) -> Self {
453 self.auto_download = enabled;
454 self
455 }
456
457 #[must_use]
460 pub const fn force_download(mut self, enabled: bool) -> Self {
461 self.force_download = enabled;
462 self
463 }
464
465 #[must_use]
467 pub const fn progress(mut self, enabled: bool) -> Self {
468 self.progress = enabled;
469 self
470 }
471
472 fn resolve_explicit(&self) -> Result<Option<Distribution>, String> {
473 if let Some(path) = self.distribution.as_deref() {
474 let distribution = Distribution::open(path)?;
475 ensure_runtime_compatibility(&distribution.manifest().target)?;
476 tracing::info!(
477 framework = %distribution.root().display(),
478 source = DIR_ENV,
479 "using developer-managed Tuffite Framework Distribution"
480 );
481 return Ok(Some(distribution));
482 }
483 Ok(None)
484 }
485
486 fn resolve_local(&self, compatible_targets: &[String]) -> Result<Option<Distribution>, String> {
487 if self.force_download {
488 return Ok(None);
489 }
490 let profile = "release";
491 if let Some(framework) = self.framework_directory.as_deref()
492 && let Some(distribution) = local_distribution(framework, profile, compatible_targets)?
493 {
494 ensure_runtime_compatibility(&distribution.manifest().target)?;
495 tracing::info!(framework = %distribution.root().display(), "using local Framework Framework Distribution");
496 return Ok(Some(distribution));
497 }
498 Ok(None)
499 }
500
501 fn managed_cache_root(&self, repository: &str, tag: &str) -> Option<PathBuf> {
502 let root = self.cache_root.clone().or_else(cache_root)?;
503 if self.repository.is_some() || self.release_tag.is_some() {
504 let identity = Sha256::digest(format!("{repository}\0{tag}").as_bytes());
505 Some(root.join("sources").join(format!("{identity:x}")))
506 } else {
507 Some(root)
508 }
509 }
510
511 pub fn resolve(self) -> Result<Distribution, String> {
512 if let Some(distribution) = self.resolve_explicit()? {
513 return Ok(distribution);
514 }
515 let target_is_explicit = self.target.is_some();
516 let requested_target = self.target.clone().unwrap_or_else(host_target);
517 validate_release_identifier(&requested_target, "target")?;
518 let compatible_targets = compatible_targets(&requested_target, target_is_explicit);
519 if let Some(distribution) = self.resolve_local(&compatible_targets)? {
520 return Ok(distribution);
521 }
522 let repository = resolve_repository(self.repository.as_deref())?;
523 let tag = self.release_tag.clone().unwrap_or_else(default_release_tag);
524 validate_release_identifier(&tag, "release tag")?;
525 let cache_root = self.managed_cache_root(&repository, &tag);
526
527 if !self.force_download {
528 if let Some(destination) = self.download_directory.as_deref() {
529 if let Ok(distribution) = Distribution::open(destination)
530 && compatible_targets.contains(&distribution.manifest().target)
531 {
532 report_target_fallback(&requested_target, &distribution.manifest().target);
533 ensure_runtime_compatibility(&distribution.manifest().target)?;
534 tracing::info!(
535 framework = %distribution.root().display(),
536 "using cached Tuffite Framework Distribution"
537 );
538 return Ok(distribution);
539 }
540 } else if let Some(root) = cache_root.as_deref() {
541 for target in &compatible_targets {
542 let destination = root.join(env!("CARGO_PKG_VERSION")).join(target);
543 if let Ok(distribution) = Distribution::open(&destination) {
544 report_target_fallback(&requested_target, target);
545 ensure_runtime_compatibility(target)?;
546 tracing::info!(
547 framework = %distribution.root().display(),
548 cache_root = %root.display(),
549 "using cached Tuffite Framework Distribution"
550 );
551 return Ok(distribution);
552 }
553 }
554 }
555 }
556 if !self.auto_download {
557 return Err(format!(
558 "no compatible Tuffite Framework Distribution for Tuffite {} targets {}; automatic download is disabled by {AUTO_DOWNLOAD_ENV}. Set {DIR_ENV} to a developer-managed Distribution or enable downloads",
559 env!("CARGO_PKG_VERSION"),
560 compatible_targets.join(", "),
561 ));
562 }
563
564 let token = github_token_optional();
565 let release = github_release(&repository, &tag, token.as_deref())?
566 .ok_or_else(|| format!("GitHub Release {tag} does not exist in {repository}"))?;
567 let (target, asset_id) = select_release_asset(&release, &compatible_targets).ok_or_else(|| {
568 let assets = compatible_targets
569 .iter()
570 .map(|target| release_asset_name(target))
571 .collect::<Vec<_>>()
572 .join(", ");
573 format!(
574 "GitHub Release {tag} in {repository} does not contain a compatible Framework asset; looked for {assets}"
575 )
576 })?;
577 tracing::info!(
578 repository,
579 tag,
580 asset = %release_asset_name(target),
581 target,
582 "selected GitHub Release Framework asset"
583 );
584 report_target_fallback(&requested_target, target);
585 ensure_runtime_compatibility(target)?;
586 let destination = if let Some(path) = self.download_directory {
587 path
588 } else {
589 cache_root
590 .ok_or_else(|| {
591 "unable to determine the Tuffite Framework cache directory".to_owned()
592 })?
593 .join(env!("CARGO_PKG_VERSION"))
594 .join(target)
595 };
596 tracing::info!(
597 framework = %destination.display(),
598 "Tuffite Framework download destination"
599 );
600 download_distribution(
601 &repository,
602 &tag,
603 target,
604 asset_id,
605 token.as_deref(),
606 &destination,
607 self.progress,
608 )?;
609 Distribution::open(destination)
610 }
611}
612
613fn compatible_targets(requested: &str, explicit: bool) -> Vec<String> {
614 let mut targets = vec![requested.to_owned()];
615 if !explicit && requested == "aarch64-apple-darwin" {
616 targets.push("x86_64-apple-darwin".to_owned());
617 }
618 targets
619}
620
621fn report_target_fallback(requested: &str, selected: &str) {
622 if selected != requested {
623 tracing::warn!(
624 requested_target = requested,
625 selected_target = selected,
626 "native macOS ARM64 Framework is unavailable; using the x86_64 artifact through Rosetta 2"
627 );
628 }
629}
630
631fn select_release_asset<'a>(
632 release: &'a GithubRelease,
633 targets: &'a [String],
634) -> Option<(&'a str, u64)> {
635 targets.iter().find_map(|target| {
636 let name = release_asset_name(target);
637 release
638 .assets
639 .iter()
640 .find(|asset| asset.name == name)
641 .map(|asset| (target.as_str(), asset.id))
642 })
643}
644
645fn ensure_runtime_compatibility(target: &str) -> Result<(), String> {
646 if cfg!(all(target_os = "macos", target_arch = "aarch64")) && target == "x86_64-apple-darwin" {
647 let available = Command::new("/usr/bin/arch")
648 .args(["-x86_64", "/usr/bin/true"])
649 .status()
650 .is_ok_and(|status| status.success());
651 if !available {
652 return Err(
653 "the available Framework is x86_64, but Rosetta 2 is not installed. Install Rosetta or publish tuffite-framework-aarch64-apple-darwin.zip"
654 .to_owned(),
655 );
656 }
657 }
658 Ok(())
659}
660
661pub fn resolve() -> Result<Distribution, String> {
663 Resolver::from_env()?.resolve()
664}
665
666pub fn progress_enabled() -> Result<bool, String> {
669 env::var(PROGRESS_ENV).map_or(Ok(true), |value| parse_env_bool(PROGRESS_ENV, &value))
670}
671
672#[must_use]
673pub fn cache_root() -> Option<PathBuf> {
674 if let Some(path) = env::var_os(CACHE_ENV) {
675 return Some(PathBuf::from(path));
676 }
677 if cfg!(target_os = "windows") {
678 env::var_os("LOCALAPPDATA")
679 .map(|path| PathBuf::from(path).join("Tuffite").join("framework"))
680 } else if let Some(path) = env::var_os("XDG_CACHE_HOME") {
681 Some(PathBuf::from(path).join("tuffite").join("framework"))
682 } else {
683 env::var_os("HOME").map(|path| {
684 PathBuf::from(path)
685 .join(".cache")
686 .join("tuffite")
687 .join("framework")
688 })
689 }
690}
691
692#[must_use]
693pub fn host_target() -> String {
694 if cfg!(target_os = "windows") {
695 format!("{}-pc-windows-msvc", env::consts::ARCH)
696 } else if cfg!(target_os = "macos") {
697 format!("{}-apple-darwin", env::consts::ARCH)
698 } else {
699 format!("{}-unknown-linux-gnu", env::consts::ARCH)
700 }
701}
702
703#[derive(Deserialize)]
704struct GithubRelease {
705 assets: Vec<GithubAsset>,
706}
707
708#[derive(Deserialize)]
709struct GithubAsset {
710 id: u64,
711 name: String,
712}
713
714fn download_distribution(
715 repository: &str,
716 tag: &str,
717 target: &str,
718 asset_id: u64,
719 token: Option<&str>,
720 destination: &Path,
721 progress: bool,
722) -> Result<(), String> {
723 let parent = destination.parent().ok_or_else(|| {
724 format!(
725 "Framework download destination has no parent: {}",
726 destination.display()
727 )
728 })?;
729 fs::create_dir_all(parent).map_err(|error| format!("create {}: {error}", parent.display()))?;
730 let asset = release_asset_name(target);
731 let url = format!("https://api.github.com/repos/{repository}/releases/assets/{asset_id}");
732 let archive = parent.join(format!(".{asset}.{}.download", std::process::id()));
733 let staging = parent.join(format!(
734 ".{}.{}.staging",
735 destination
736 .file_name()
737 .and_then(|value| value.to_str())
738 .unwrap_or("framework"),
739 std::process::id()
740 ));
741 remove_path(&archive)?;
742 remove_path(&staging)?;
743 let result = (|| {
744 tracing::info!(repository, tag, asset, "downloading Framework Distribution");
745 download_archive(&url, &archive, token, progress)?;
746 extract_archive(&archive, &staging)?;
747 let distribution = Distribution::open(&staging)?;
748 if distribution.manifest().target != target {
749 return Err(format!(
750 "downloaded Framework target {} does not match requested {target}",
751 distribution.manifest().target
752 ));
753 }
754 distribution.runtime_files(true)?;
755 distribution.bootstrap(true)?;
756 install_distribution(&staging, destination)?;
757 tracing::info!(
758 framework = %destination.display(),
759 repository,
760 tag,
761 asset,
762 "installed downloaded Tuffite Framework Distribution"
763 );
764 Ok(())
765 })();
766 let _ = remove_path(&archive);
767 if result.is_err() {
768 let _ = remove_path(&staging);
769 }
770 result
771}
772
773fn install_distribution(staging: &Path, destination: &Path) -> Result<(), String> {
774 let parent = destination.parent().ok_or_else(|| {
775 format!(
776 "Framework destination has no parent: {}",
777 destination.display()
778 )
779 })?;
780 let backup = parent.join(format!(
781 ".{}.{}.backup",
782 destination
783 .file_name()
784 .and_then(|value| value.to_str())
785 .unwrap_or("framework"),
786 std::process::id()
787 ));
788 remove_path(&backup)?;
789 let had_destination = destination.exists();
790 if had_destination {
791 fs::rename(destination, &backup).map_err(|error| {
792 format!(
793 "prepare to replace Framework {}: {error}",
794 destination.display()
795 )
796 })?;
797 }
798 if let Err(error) = fs::rename(staging, destination) {
799 if had_destination {
800 let _ = fs::rename(&backup, destination);
801 }
802 return Err(format!(
803 "install downloaded Framework {}: {error}",
804 destination.display()
805 ));
806 }
807 if had_destination {
808 remove_path(&backup)?;
809 }
810 Ok(())
811}
812
813fn download_archive(
814 url: &str,
815 destination: &Path,
816 token: Option<&str>,
817 progress: bool,
818) -> Result<(), String> {
819 let mut request = ureq::get(url)
820 .header("User-Agent", "tuffite-build")
821 .header("Accept", "application/octet-stream")
822 .header("X-GitHub-Api-Version", GITHUB_API_VERSION);
823 if let Some(token) = token {
824 request = request.header("Authorization", &format!("Bearer {token}"));
825 }
826 let response = request
827 .call()
828 .map_err(|error| format!("download {url}: {error}"))?;
829 let total = response
830 .headers()
831 .get("content-length")
832 .and_then(|value| value.to_str().ok())
833 .and_then(|value| value.parse().ok());
834 let reader = response.into_body().into_reader();
835 let mut reader = TransferProgress::new(reader, total, "download", progress);
836 let mut output = File::create(destination)
837 .map_err(|error| format!("create {}: {error}", destination.display()))?;
838 io::copy(&mut reader, &mut output)
839 .map_err(|error| format!("write {}: {error}", destination.display()))?;
840 output
841 .sync_all()
842 .map_err(|error| format!("sync {}: {error}", destination.display()))
843}
844
845pub(crate) struct TransferProgress<R> {
846 reader: R,
847 total: Option<u64>,
848 transferred: u64,
849 next_report: u64,
850 action: &'static str,
851 enabled: bool,
852 interactive: bool,
853 progress_bar: Option<ProgressBar>,
854 finished: bool,
855}
856
857impl<R> TransferProgress<R> {
858 pub(crate) fn new(reader: R, total: Option<u64>, action: &'static str, enabled: bool) -> Self {
859 let interactive = enabled && io::stderr().is_terminal();
860 let progress_bar = interactive.then(|| {
861 let bar = total.map_or_else(ProgressBar::no_length, ProgressBar::new);
862 bar.set_draw_target(ProgressDrawTarget::stderr_with_hz(10));
863 let template = if total.is_some() {
864 "{spinner:.cyan} {msg} [{bar:36.cyan/blue}] {bytes}/{total_bytes} {bytes_per_sec} ETA {eta}"
865 } else {
866 "{spinner:.cyan} {msg} {bytes} {bytes_per_sec} {elapsed_precise}"
867 };
868 bar.set_style(
869 ProgressStyle::with_template(template)
870 .expect("the Tuffite transfer progress template must be valid")
871 .progress_chars("=>-"),
872 );
873 bar.set_message(match action {
874 "upload" => "Uploading Framework",
875 "download" => "Downloading Framework",
876 _ => "Transferring Framework",
877 });
878 bar
879 });
880 Self {
881 reader,
882 total,
883 transferred: 0,
884 next_report: 64 * 1024 * 1024,
885 action,
886 enabled,
887 interactive,
888 progress_bar,
889 finished: false,
890 }
891 }
892
893 fn report(&mut self, finished: bool) {
894 if let Some(bar) = &self.progress_bar {
895 bar.set_position(self.transferred);
896 if finished && !self.finished {
897 bar.finish_and_clear();
898 self.finished = true;
899 }
900 return;
901 }
902 if !self.enabled
903 || self.interactive
904 || self.finished
905 || (!finished && self.transferred < self.next_report)
906 {
907 return;
908 }
909 self.next_report = self.transferred.saturating_add(64 * 1024 * 1024);
910 self.finished = finished;
911 if let Some(total) = self.total {
912 let percent = if total == 0 {
913 100
914 } else {
915 (self.transferred.saturating_mul(100) / total).min(100)
916 };
917 if finished {
918 tracing::info!(
919 action = self.action,
920 bytes = self.transferred,
921 total_bytes = total,
922 percent,
923 "Framework transfer complete"
924 );
925 } else {
926 tracing::info!(
927 action = self.action,
928 bytes = self.transferred,
929 total_bytes = total,
930 percent,
931 "Framework transfer progress"
932 );
933 }
934 } else if finished {
935 tracing::info!(
936 action = self.action,
937 bytes = self.transferred,
938 "Framework transfer complete"
939 );
940 } else {
941 tracing::info!(
942 action = self.action,
943 bytes = self.transferred,
944 "Framework transfer progress"
945 );
946 }
947 }
948}
949
950impl<R: io::Read> io::Read for TransferProgress<R> {
951 fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
952 let read = self.reader.read(buffer)?;
953 self.transferred = self.transferred.saturating_add(read as u64);
954 self.report(read == 0);
955 Ok(read)
956 }
957}
958
959impl<R> Drop for TransferProgress<R> {
960 fn drop(&mut self) {
961 if let Some(bar) = &self.progress_bar {
962 bar.finish_and_clear();
963 }
964 }
965}
966
967fn extract_archive(archive: &Path, destination: &Path) -> Result<(), String> {
968 fs::create_dir_all(destination)
969 .map_err(|error| format!("create {}: {error}", destination.display()))?;
970 let file = File::open(archive)
971 .map_err(|error| format!("open downloaded archive {}: {error}", archive.display()))?;
972 let mut archive = ZipArchive::new(file)
973 .map_err(|error| format!("open ZIP {}: {error}", archive.display()))?;
974 for index in 0..archive.len() {
975 let mut entry = archive
976 .by_index(index)
977 .map_err(|error| format!("read ZIP entry: {error}"))?;
978 let relative = entry
979 .enclosed_name()
980 .ok_or_else(|| format!("unsafe ZIP entry: {}", entry.name()))?;
981 let output = destination.join(relative);
982 if entry.is_dir() {
983 fs::create_dir_all(&output)
984 .map_err(|error| format!("create {}: {error}", output.display()))?;
985 continue;
986 }
987 if entry
988 .unix_mode()
989 .is_some_and(|mode| mode & 0o170_000 == 0o120_000)
990 {
991 return Err(format!("ZIP entry is a symbolic link: {}", entry.name()));
992 }
993 if let Some(parent) = output.parent() {
994 fs::create_dir_all(parent)
995 .map_err(|error| format!("create {}: {error}", parent.display()))?;
996 }
997 let mut file = File::create(&output)
998 .map_err(|error| format!("create {}: {error}", output.display()))?;
999 io::copy(&mut entry, &mut file)
1000 .map_err(|error| format!("extract {}: {error}", output.display()))?;
1001 file.flush()
1002 .map_err(|error| format!("flush {}: {error}", output.display()))?;
1003 restore_permissions(&output, entry.unix_mode())?;
1004 }
1005 Ok(())
1006}
1007
1008#[cfg_attr(not(unix), allow(clippy::unnecessary_wraps))]
1009fn restore_permissions(path: &Path, mode: Option<u32>) -> Result<(), String> {
1010 #[cfg(unix)]
1011 {
1012 use std::os::unix::fs::PermissionsExt as _;
1013 if let Some(mode) = mode {
1014 fs::set_permissions(path, fs::Permissions::from_mode(mode & 0o777))
1015 .map_err(|error| format!("set permissions on {}: {error}", path.display()))?;
1016 }
1017 }
1018 #[cfg(not(unix))]
1019 {
1020 let _ = (path, mode);
1021 }
1022 Ok(())
1023}
1024
1025fn github_release(
1026 repository: &str,
1027 tag: &str,
1028 token: Option<&str>,
1029) -> Result<Option<GithubRelease>, String> {
1030 let url = format!("https://api.github.com/repos/{repository}/releases/tags/{tag}");
1031 let mut request = ureq::get(&url)
1032 .header("User-Agent", "tuffite-build")
1033 .header("Accept", "application/vnd.github+json")
1034 .header("X-GitHub-Api-Version", GITHUB_API_VERSION);
1035 if let Some(token) = token {
1036 request = request.header("Authorization", &format!("Bearer {token}"));
1037 }
1038 let mut response = match request.call() {
1039 Ok(response) => response,
1040 Err(ureq::Error::StatusCode(404)) => return Ok(None),
1041 Err(error) => return Err(format!("read GitHub Release {tag}: {error}")),
1042 };
1043 response
1044 .body_mut()
1045 .read_json()
1046 .map(Some)
1047 .map_err(|error| format!("parse GitHub Release {tag}: {error}"))
1048}
1049
1050fn resolve_repository(override_repository: Option<&str>) -> Result<String, String> {
1051 if let Some(repository) = override_repository {
1052 validate_repository(repository)?;
1053 return Ok(repository.to_owned());
1054 }
1055 let repository_url = env!("CARGO_PKG_REPOSITORY");
1056 let url = Url::parse(repository_url)
1057 .map_err(|error| format!("parse compiled repository URL {repository_url}: {error}"))?;
1058 if url.host_str() != Some("github.com") {
1059 return Err(format!(
1060 "compiled package repository is not hosted on GitHub: {repository_url}"
1061 ));
1062 }
1063 let mut segments = url
1064 .path_segments()
1065 .ok_or_else(|| format!("compiled repository URL has no path: {repository_url}"))?
1066 .filter(|segment| !segment.is_empty());
1067 let owner = segments
1068 .next()
1069 .ok_or_else(|| format!("compiled repository URL has no owner: {repository_url}"))?;
1070 let name = segments
1071 .next()
1072 .ok_or_else(|| format!("compiled repository URL has no name: {repository_url}"))?
1073 .trim_end_matches(".git");
1074 if segments.next().is_some() {
1075 return Err(format!(
1076 "compiled repository URL is not a GitHub repository root: {repository_url}"
1077 ));
1078 }
1079 let repository = format!("{owner}/{name}");
1080 validate_repository(&repository)?;
1081 Ok(repository)
1082}
1083
1084fn validate_repository(repository: &str) -> Result<(), String> {
1085 let mut parts = repository.split('/');
1086 let valid = parts.next().is_some_and(valid_github_name)
1087 && parts.next().is_some_and(valid_github_name)
1088 && parts.next().is_none();
1089 if valid {
1090 Ok(())
1091 } else {
1092 Err(format!(
1093 "invalid GitHub repository {repository:?}; expected owner/name"
1094 ))
1095 }
1096}
1097
1098fn valid_github_name(value: &str) -> bool {
1099 !value.is_empty()
1100 && value
1101 .bytes()
1102 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1103}
1104
1105fn validate_release_identifier(value: &str, label: &str) -> Result<(), String> {
1106 if !value.is_empty()
1107 && value
1108 .bytes()
1109 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1110 {
1111 Ok(())
1112 } else {
1113 Err(format!("invalid Framework {label} {value:?}"))
1114 }
1115}
1116
1117fn parse_env_bool(name: &str, value: &str) -> Result<bool, String> {
1118 match value.trim().to_ascii_lowercase().as_str() {
1119 "1" | "true" | "yes" | "on" => Ok(true),
1120 "0" | "false" | "no" | "off" => Ok(false),
1121 _ => Err(format!(
1122 "{name} must be one of 1, 0, true, false, yes, no, on, or off; got {value:?}"
1123 )),
1124 }
1125}
1126
1127fn default_release_tag() -> String {
1128 format!("v{}", env!("CARGO_PKG_VERSION"))
1129}
1130
1131#[must_use]
1132pub fn release_asset_name(target: &str) -> String {
1133 format!("tuffite-framework-{target}.zip")
1134}
1135
1136fn github_token_optional() -> Option<String> {
1137 env::var("GITHUB_TOKEN")
1138 .ok()
1139 .filter(|value| !value.is_empty())
1140 .or_else(|| env::var("GH_TOKEN").ok().filter(|value| !value.is_empty()))
1141 .or_else(github_cli_token)
1142}
1143
1144fn github_cli_token() -> Option<String> {
1145 let output = Command::new("gh").args(["auth", "token"]).output().ok()?;
1146 if !output.status.success() {
1147 return None;
1148 }
1149 let token = String::from_utf8(output.stdout).ok()?;
1150 let token = token.trim();
1151 (!token.is_empty()).then(|| token.to_owned())
1152}
1153
1154fn remove_path(path: &Path) -> Result<(), String> {
1155 if !path.exists() {
1156 return Ok(());
1157 }
1158 if path.is_dir() {
1159 fs::remove_dir_all(path).map_err(|error| format!("remove {}: {error}", path.display()))
1160 } else {
1161 fs::remove_file(path).map_err(|error| format!("remove {}: {error}", path.display()))
1162 }
1163}
1164
1165fn safe_join(root: &Path, value: &str, field: &str) -> Result<PathBuf, String> {
1166 Ok(root.join(safe_relative(value, field)?))
1167}
1168
1169fn safe_relative(value: &str, field: &str) -> Result<PathBuf, String> {
1170 let path = Path::new(value);
1171 if value.is_empty() || path.is_absolute() {
1172 return Err(format!("invalid Framework {field}: {value}"));
1173 }
1174 let mut result = PathBuf::new();
1175 for component in path.components() {
1176 match component {
1177 Component::Normal(part) if part != OsStr::new("") => result.push(part),
1178 _ => return Err(format!("unsafe Framework {field}: {value}")),
1179 }
1180 }
1181 Ok(result)
1182}
1183
1184fn hash_file(path: &Path) -> Result<String, String> {
1185 use std::io::Read as _;
1186 let mut file = File::open(path).map_err(|error| format!("open {}: {error}", path.display()))?;
1187 let mut hasher = Sha256::new();
1188 let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
1189 loop {
1190 let read = file
1191 .read(&mut buffer)
1192 .map_err(|error| format!("read {}: {error}", path.display()))?;
1193 if read == 0 {
1194 break;
1195 }
1196 hasher.update(&buffer[..read]);
1197 }
1198 Ok(format!("{:x}", hasher.finalize()))
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203 use std::io::Read as _;
1204
1205 use super::{
1206 GithubAsset, GithubRelease, Resolver, TransferProgress, compatible_targets, parse_env_bool,
1207 release_asset_name, select_release_asset, validate_release_identifier,
1208 };
1209
1210 #[test]
1211 fn boolean_environment_values_are_explicit() {
1212 for value in ["1", "true", "YES", "on"] {
1213 assert!(parse_env_bool("TEST", value).unwrap());
1214 }
1215 for value in ["0", "false", "NO", "off"] {
1216 assert!(!parse_env_bool("TEST", value).unwrap());
1217 }
1218 assert!(parse_env_bool("TEST", "sometimes").is_err());
1219 }
1220
1221 #[test]
1222 fn release_identifiers_cannot_escape_urls_or_paths() {
1223 assert!(validate_release_identifier("x86_64-pc-windows-msvc", "target").is_ok());
1224 assert!(validate_release_identifier("../windows", "target").is_err());
1225 assert_eq!(
1226 release_asset_name("aarch64-apple-darwin"),
1227 "tuffite-framework-aarch64-apple-darwin.zip"
1228 );
1229 }
1230
1231 #[test]
1232 fn macos_arm_can_fall_back_to_x86_64_only_when_target_is_implicit() {
1233 assert_eq!(
1234 compatible_targets("aarch64-apple-darwin", false),
1235 ["aarch64-apple-darwin", "x86_64-apple-darwin"]
1236 );
1237 assert_eq!(
1238 compatible_targets("aarch64-apple-darwin", true),
1239 ["aarch64-apple-darwin"]
1240 );
1241 assert_eq!(
1242 compatible_targets("aarch64-unknown-linux-gnu", false),
1243 ["aarch64-unknown-linux-gnu"]
1244 );
1245 }
1246
1247 #[test]
1248 fn release_selection_prefers_native_then_rosetta_asset() {
1249 let targets = compatible_targets("aarch64-apple-darwin", false);
1250 let x64_only = GithubRelease {
1251 assets: vec![GithubAsset {
1252 id: 7,
1253 name: release_asset_name("x86_64-apple-darwin"),
1254 }],
1255 };
1256 assert_eq!(
1257 select_release_asset(&x64_only, &targets),
1258 Some(("x86_64-apple-darwin", 7))
1259 );
1260 let both = GithubRelease {
1261 assets: vec![
1262 GithubAsset {
1263 id: 7,
1264 name: release_asset_name("x86_64-apple-darwin"),
1265 },
1266 GithubAsset {
1267 id: 8,
1268 name: release_asset_name("aarch64-apple-darwin"),
1269 },
1270 ],
1271 };
1272 assert_eq!(
1273 select_release_asset(&both, &targets),
1274 Some(("aarch64-apple-darwin", 8))
1275 );
1276 }
1277
1278 #[test]
1279 fn disabling_download_fails_without_network_access() {
1280 let destination = std::env::temp_dir().join(format!(
1281 "tuffite-missing-framework-{}-{}",
1282 std::process::id(),
1283 line!()
1284 ));
1285 let error = Resolver::new()
1286 .download_directory(destination)
1287 .target("x86_64-test-none")
1288 .auto_download(false)
1289 .resolve()
1290 .unwrap_err();
1291 assert!(error.contains("automatic download is disabled"), "{error}");
1292 }
1293
1294 #[test]
1295 fn progress_reader_preserves_the_stream() {
1296 let input = b"tuffite".as_slice();
1297 let mut reader = TransferProgress::new(input, Some(6), "tested", false);
1298 let mut output = Vec::new();
1299 reader.read_to_end(&mut output).unwrap();
1300 assert_eq!(output, b"tuffite");
1301 }
1302 struct LocalFixture(std::path::PathBuf);
1303 impl LocalFixture {
1304 fn new() -> Self {
1305 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1306 let root = std::env::temp_dir().join(format!(
1307 "tuffite-local-{}-{}",
1308 std::process::id(),
1309 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1310 ));
1311 std::fs::create_dir_all(&root).unwrap();
1312 Self(root)
1313 }
1314 fn artifact(&self, path: &str, target: &str) -> std::path::PathBuf {
1315 let root = self.0.join(path);
1316 std::fs::create_dir_all(root.join("lib")).unwrap();
1317 std::fs::create_dir_all(root.join("runtime")).unwrap();
1318 std::fs::write(root.join("launcher"), []).unwrap();
1319 let mut runtime_files = vec![
1320 serde_json::json!({"path": "runtime.bin", "size": 0, "sha256": "0".repeat(64)}),
1321 ];
1322 for name in [
1323 "LICENSE.tuffite.txt",
1324 "LICENSE.chromium.txt",
1325 "THIRD_PARTY_NOTICES.chromium.txt",
1326 ] {
1327 let path = format!("licenses/{name}");
1328 std::fs::create_dir_all(root.join("runtime/licenses")).unwrap();
1329 std::fs::write(root.join("runtime").join(&path), b"license").unwrap();
1330 runtime_files
1331 .push(serde_json::json!({"path": path, "size": 7, "sha256": "0".repeat(64)}));
1332 }
1333 let manifest = serde_json::json!({
1334 "framework_version": env!("CARGO_PKG_VERSION"), "chromium_revision": "fixture", "target": target,
1335 "profile": "release", "variant": "full", "component_build": false, "features": [],
1336 "ffi_abi_major": tuffite_abi::ABI_MAJOR, "ffi_abi_minor": tuffite_abi::ABI_MINOR, "ffi_abi_patch": tuffite_abi::ABI_PATCH,
1337 "link_directory": "lib", "runtime_directory": "runtime",
1338 "bootstrap": {"path": "launcher", "size": 0, "sha256": "0".repeat(64)},
1339 "runtime_files": runtime_files});
1340 std::fs::write(
1341 root.join(super::MANIFEST_FILE),
1342 serde_json::to_vec(&manifest).unwrap(),
1343 )
1344 .unwrap();
1345 root.canonicalize().unwrap()
1346 }
1347 }
1348 impl Drop for LocalFixture {
1349 fn drop(&mut self) {
1350 let _ = std::fs::remove_dir_all(&self.0);
1351 }
1352 }
1353 #[test]
1354 fn legacy_distribution_without_license_entries_requires_repackaging() {
1355 let fixture = LocalFixture::new();
1356 let root = fixture.artifact("legacy", "x86_64-apple-darwin");
1357 let path = root.join(super::MANIFEST_FILE);
1358 let mut manifest: serde_json::Value =
1359 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1360 manifest["runtime_files"]
1361 .as_array_mut()
1362 .unwrap()
1363 .retain(|file| !file["path"].as_str().unwrap().starts_with("licenses/"));
1364 std::fs::write(path, serde_json::to_vec(&manifest).unwrap()).unwrap();
1365 let error = super::Distribution::open(root).unwrap_err();
1366 assert!(error.contains("missing notice"), "{error}");
1367 }
1368
1369 #[test]
1370 fn local_framework_output_precedes_managed_cache() {
1371 let fixture = LocalFixture::new();
1372 let target = super::host_target();
1373 let cpu = if target.starts_with("aarch64") {
1374 "arm64"
1375 } else {
1376 "x64"
1377 };
1378 let local = fixture.artifact(&format!("dist/tuffite-framework-{cpu}"), &target);
1379 fixture.artifact(
1380 &format!("cache/{}/{target}", env!("CARGO_PKG_VERSION")),
1381 &target,
1382 );
1383 let resolved = Resolver::new()
1384 .framework_directory(&fixture.0)
1385 .cache_root(fixture.0.join("cache"))
1386 .auto_download(false)
1387 .target(target)
1388 .resolve()
1389 .unwrap();
1390 assert_eq!(resolved.root(), local);
1391 }
1392 #[test]
1393 fn managed_resolution_bypasses_local_framework_output() {
1394 let fixture = LocalFixture::new();
1395 let target = super::host_target();
1396 let cache = fixture.artifact(
1397 &format!("cache/{}/{target}", env!("CARGO_PKG_VERSION")),
1398 &target,
1399 );
1400 let cpu = if target.starts_with("aarch64") {
1401 "arm64"
1402 } else {
1403 "x64"
1404 };
1405 let local = fixture.artifact(&format!("dist/tuffite-framework-{cpu}"), &target);
1406 let resolver = Resolver::new()
1407 .framework_directory(&fixture.0)
1408 .cache_root(fixture.0.join("cache"))
1409 .target(target)
1410 .auto_download(false);
1411 assert_eq!(resolver.clone().resolve().unwrap().root(), local);
1412 assert_eq!(resolver.managed().resolve().unwrap().root(), cache);
1413 }
1414 #[test]
1415 fn custom_release_does_not_reuse_default_or_other_source_cache() {
1416 use sha2::Digest as _;
1417 let fixture = LocalFixture::new();
1418 let target = super::host_target();
1419 let cache_root = fixture.0.join("cache");
1420 fixture.artifact(
1421 &format!("cache/{}/{target}", env!("CARGO_PKG_VERSION")),
1422 &target,
1423 );
1424 for resolver in [
1425 Resolver::new().repository("example/framework"),
1426 Resolver::new().release_tag("v-custom"),
1427 ] {
1428 assert!(
1429 resolver
1430 .target(&target)
1431 .cache_root(&cache_root)
1432 .auto_download(false)
1433 .resolve()
1434 .is_err()
1435 );
1436 }
1437 let resolver = Resolver::new()
1438 .repository("example/framework")
1439 .release_tag("v-custom")
1440 .target(&target)
1441 .cache_root(&cache_root)
1442 .auto_download(false);
1443 assert!(
1444 resolver
1445 .clone()
1446 .resolve()
1447 .unwrap_err()
1448 .contains("automatic download is disabled")
1449 );
1450 let identity = super::Sha256::digest(b"example/framework\0v-custom");
1451 let cached = fixture.artifact(
1452 &format!(
1453 "cache/sources/{identity:x}/{}/{target}",
1454 env!("CARGO_PKG_VERSION")
1455 ),
1456 &target,
1457 );
1458 assert_eq!(resolver.clone().resolve().unwrap().root(), cached);
1459 assert!(resolver.clone().release_tag("v-other").resolve().is_err());
1460 assert!(resolver.repository("other/framework").resolve().is_err());
1461 }
1462
1463 #[test]
1464 fn local_discovery_checks_full_target_and_explicit_override_wins() {
1465 let fixture = LocalFixture::new();
1466 let target = super::host_target();
1467 let cpu = if target.starts_with("aarch64") {
1468 "arm64"
1469 } else {
1470 "x64"
1471 };
1472 fixture.artifact(
1473 &format!("dist/tuffite-framework-{cpu}"),
1474 "x86_64-other-platform",
1475 );
1476 assert!(
1477 super::local_distribution(&fixture.0, "release", std::slice::from_ref(&target))
1478 .unwrap()
1479 .is_none()
1480 );
1481 let explicit = fixture.artifact("explicit", &target);
1482 assert_eq!(
1483 Resolver::new()
1484 .framework_directory(&fixture.0)
1485 .distribution(&explicit)
1486 .resolve()
1487 .unwrap()
1488 .root(),
1489 explicit
1490 );
1491 }
1492}