tuffite/build/
framework.rs

1//! Tuffite Framework Distribution discovery, validation, and Cargo linking.
2
3use 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
22/// Framework checkout used to discover locally packaged native output.
23pub const FRAMEWORK_ENV: &str = "TUFFITE_FRAMEWORK_DIR";
24/// Exact developer-managed Distribution; never downloaded into or replaced.
25pub const DIR_ENV: &str = "TUFFITE_FRAMEWORK_DIST";
26/// Root of Tuffite's versioned Framework cache.
27pub const CACHE_ENV: &str = "TUFFITE_FRAMEWORK_DIST_CACHE";
28/// Exact destination for an automatically downloaded Distribution.
29pub const DOWNLOAD_DIR_ENV: &str = "TUFFITE_FRAMEWORK_DIST_DOWNLOAD_DIR";
30/// Explicit Framework Rust target triple.
31pub const TARGET_ENV: &str = "TUFFITE_FRAMEWORK_DIST_TARGET";
32/// Enables or disables automatic download on a cache miss.
33pub const AUTO_DOWNLOAD_ENV: &str = "TUFFITE_FRAMEWORK_DIST_AUTO_DOWNLOAD";
34/// Enables or disables transfer progress messages for automatic downloads.
35pub 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";
38/// Environment variables that affect Framework resolution in a Cargo build script.
39pub 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    /// Compatible ABI patch release.
73    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        // x.py owns canonical naming; inspect manifests to prevent cross-platform reuse.
304        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/// Resolves a local Framework Distribution and, by default, downloads a missing
329/// version-matched GitHub Release asset into the user cache.
330#[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    /// Loads resolver overrides from the Tuffite environment variables.
368    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    /// Uses an existing developer-managed Distribution and never replaces it.
394    #[must_use]
395    pub fn distribution(mut self, path: impl Into<PathBuf>) -> Self {
396        self.distribution = Some(path.into());
397        self
398    }
399
400    /// Ignores local Distribution and Framework checkout overrides and resolves
401    /// a Tuffite-managed cache/download destination.
402    #[must_use]
403    pub fn managed(mut self) -> Self {
404        self.distribution = None;
405        self.framework_directory = None;
406        self
407    }
408
409    /// Prefer a compatible locally packaged Framework Distribution.
410    #[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    /// Sets the exact directory where an automatic download is installed.
423    #[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    /// Selects the GitHub release repository as `owner/name`. Custom repositories
436    /// and release tags use separate managed cache namespaces.
437    #[must_use]
438    pub fn repository(mut self, repository: impl Into<String>) -> Self {
439        self.repository = Some(repository.into());
440        self
441    }
442
443    /// Selects the exact GitHub release tag, independently of the SDK version.
444    #[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    /// Controls network access on a cache miss. Defaults to `true`.
451    #[must_use]
452    pub const fn auto_download(mut self, enabled: bool) -> Self {
453        self.auto_download = enabled;
454        self
455    }
456
457    /// Downloads and atomically replaces the managed destination even when a
458    /// valid Distribution is already installed.
459    #[must_use]
460    pub const fn force_download(mut self, enabled: bool) -> Self {
461        self.force_download = enabled;
462        self
463    }
464
465    /// Controls transfer progress messages. Defaults to `true`.
466    #[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
661/// Resolves the Framework using environment overrides and automatic download.
662pub fn resolve() -> Result<Distribution, String> {
663    Resolver::from_env()?.resolve()
664}
665
666/// Returns whether Framework transfers should report progress according to the
667/// process environment. Progress is enabled when the variable is absent.
668pub 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}