Skip to main content

rpi_cli/
install.rs

1//! `rpi install` — install a prebuilt or explicitly built Rust `cdylib` extension.
2//!
3//! Cargo's `cargo install` command is intended for binaries and does not copy
4//! dynamic-library targets. rpi extensions are cdylibs loaded by
5//! `rpi-extensions`. The default async path uses the prebuilt distribution
6//! protocol; the explicit source backend creates a tiny temporary Cargo workspace,
7//! resolves the requested crate through Cargo, builds the dependency in
8//! release mode, and copies its cdylib into the same global directory scanned
9//! during normal startup.
10
11use std::collections::HashSet;
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15
16use serde::{Deserialize, Serialize};
17
18use crate::prebuilt_extensions::{asset_paths_conflict, validate_asset_path};
19
20const INSTALLER_MANIFEST: &str = "rpi-extension-installer";
21const NATIVE_PACKAGES_FILE: &str = "native-packages.json";
22const NATIVE_PACKAGES_LOCK_FILE: &str = "native-packages.lock";
23const CDYLIB_EXTENSION_HINT: &str = "hint: the crate must declare `crate-type = [\"cdylib\"]` and export `rpi_plugin_register` (the unified ABI; legacy `rpi_plugin_register_v2` and `rpi_plugin_register_v3` remain supported for migration)";
24
25/// A Rust-native extension installed through `rpi install`.
26///
27/// `source` is absent for Cargo registry builds, contains the local source
28/// path for `--path`, or marks prebuilt packages so older hosts do not rebuild
29/// them. `prebuilt` stores distribution provenance. Local development crates
30/// are intentionally not eligible for automatic registry updates.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct InstalledNativePackage {
33    pub name: String,
34    pub version: String,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub source: Option<String>,
37    /// Exact prebuilt distribution provenance. Missing in older Cargo/local records.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub prebuilt: Option<crate::prebuilt_extensions::PrebuiltRecord>,
40    /// Dynamic-library file names copied into the extension store. Older
41    /// metadata files omit this field; uninstall falls back to crate-name
42    /// matching for those records.
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub artifacts: Vec<String>,
45}
46
47impl InstalledNativePackage {
48    /// Prebuilt provenance also uses `source`; only local development builds
49    /// should be excluded from registry update discovery and commands.
50    pub(crate) fn supports_registry_update(&self) -> bool {
51        self.source.is_none() || self.prebuilt.is_some()
52    }
53}
54
55/// Decide before downloads/builds, then recheck under the package mutation lock.
56/// `false` means the requested version is already installed; only --force
57/// reinstalls it. Build metadata does not change semantic-version precedence.
58pub(crate) fn should_install_version(
59    name: &str,
60    current: Option<&str>,
61    requested: &str,
62    force: bool,
63) -> Result<bool, String> {
64    let Some(current) = current.filter(|_| !force) else {
65        return Ok(true);
66    };
67    let parse = |version: &str| {
68        semver::Version::parse(version).map_err(|_| {
69            format!("cannot compare version `{version}` for `{name}`; use --force to replace it")
70        })
71    };
72    match parse(requested)?.cmp_precedence(&parse(current)?) {
73        std::cmp::Ordering::Greater => Ok(true),
74        std::cmp::Ordering::Equal => Ok(false),
75        std::cmp::Ordering::Less => Err(format!(
76            "{name} {current} is installed; use --force to downgrade to {requested}"
77        )),
78    }
79}
80
81pub(crate) fn print_already_installed(name: &str, version: &str) {
82    println!("{name} {version} is already installed; use --force to reinstall it");
83}
84
85/// The on-disk state of the Rust-native extension registry.
86///
87/// A malformed or unreadable registry is returned as an error, rather than
88/// being conflated with a missing file. Mutation paths use this distinction to
89/// avoid replacing damaged metadata with a newly generated registry.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum NativePackageRegistry {
92    /// No registry has been created yet.
93    Missing,
94    /// A registry was present and passed JSON/schema validation.
95    Valid(Vec<InstalledNativePackage>),
96}
97
98impl NativePackageRegistry {
99    fn into_records(self) -> Vec<InstalledNativePackage> {
100        match self {
101            Self::Missing => Vec::new(),
102            Self::Valid(records) => records,
103        }
104    }
105}
106
107struct NativePackageMutationLock {
108    file: Option<std::fs::File>,
109    _resource_lease: crate::resource_management::Lease,
110}
111
112impl NativePackageMutationLock {
113    fn acquire(metadata_path: &Path) -> Result<Self, String> {
114        let parent = metadata_path
115            .parent()
116            .ok_or_else(|| "native package metadata has no parent".to_string())?;
117        let resource_lease = crate::resource_management::shared_lease(parent)?;
118        Self::acquire_with_lease(metadata_path, resource_lease)
119    }
120
121    fn acquire_exclusive(metadata_path: &Path) -> Result<Self, String> {
122        let parent = metadata_path
123            .parent()
124            .ok_or_else(|| "native package metadata has no parent".to_string())?;
125        let resource_lease = crate::resource_management::mutation_lease(parent)?;
126        Self::acquire_with_lease(metadata_path, resource_lease)
127    }
128
129    fn acquire_with_lease(
130        metadata_path: &Path,
131        resource_lease: crate::resource_management::Lease,
132    ) -> Result<Self, String> {
133        let parent = metadata_path
134            .parent()
135            .ok_or_else(|| "native package metadata has no parent".to_string())?;
136        std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
137        let lock_path = parent.join(NATIVE_PACKAGES_LOCK_FILE);
138        let file = std::fs::OpenOptions::new()
139            .create(true)
140            .read(true)
141            .write(true)
142            .open(&lock_path)
143            .map_err(|error| {
144                format!(
145                    "could not open native package lock {}: {error}",
146                    lock_path.display()
147                )
148            })?;
149        fs2::FileExt::try_lock_exclusive(&file).map_err(|error| {
150            format!("could not lock native package state (another install may be running): {error}")
151        })?;
152        Ok(Self {
153            file: Some(file),
154            _resource_lease: resource_lease,
155        })
156    }
157}
158
159impl Drop for NativePackageMutationLock {
160    fn drop(&mut self) {
161        if let Some(file) = self.file.take() {
162            let _ = fs2::FileExt::unlock(&file);
163        }
164    }
165}
166
167#[derive(Debug, Clone)]
168struct InstallOptions {
169    package: String,
170    version: Option<String>,
171    path: Option<PathBuf>,
172    locked: bool,
173    force: bool,
174    build: bool,
175    repository: Option<String>,
176    cdn_base: Option<reqwest::Url>,
177}
178
179/// Default to the versioned prebuilt distribution protocol. Cargo builds are
180/// explicit (--build or --path) and run off the async worker.
181pub async fn run_async(args: &[String]) -> i32 {
182    if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
183        print_help();
184        return 0;
185    }
186    let options = match parse_args(args) {
187        Ok(options) => options,
188        Err(error) => {
189            eprintln!("error: {error}");
190            print_help();
191            return 2;
192        }
193    };
194    if let Err(error) = installed_native_packages_strict() {
195        eprintln!("error: cannot safely install while native package metadata is invalid: {error}");
196        return 1;
197    }
198    if options.build || options.path.is_some() {
199        let args = args.to_vec();
200        return match tokio::task::spawn_blocking(move || run(&args)).await {
201            Ok(code) => code,
202            Err(error) => {
203                eprintln!("error: extension build failed: {error}");
204                1
205            }
206        };
207    }
208    if crate::args::offline_env_enabled() {
209        eprintln!("error: prebuilt installation requires network access; use --build for an explicit Cargo build");
210        return 1;
211    }
212    match crate::prebuilt_extensions::install(
213        &options.package,
214        options.version.as_deref(),
215        options.repository.as_deref(),
216        options.force,
217        options.cdn_base.as_ref(),
218    )
219    .await
220    {
221        Ok(()) => 0,
222        Err(error) => {
223            eprintln!("error: {error}");
224            1
225        }
226    }
227}
228
229#[derive(Debug, Deserialize)]
230struct CargoMetadata {
231    packages: Vec<CargoPackage>,
232}
233
234#[derive(Debug, Deserialize)]
235struct CargoPackage {
236    name: String,
237    version: String,
238    targets: Vec<CargoTarget>,
239}
240
241#[derive(Debug, Deserialize)]
242struct CargoTarget {
243    name: String,
244    crate_types: Vec<String>,
245}
246
247/// Run `rpi install ...`. This is intentionally synchronous: it is a short
248/// lived package operation and keeping Cargo's build output attached to the
249/// user's terminal makes failures actionable.
250pub fn run(args: &[String]) -> i32 {
251    if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
252        print_help();
253        return 0;
254    }
255    let options = match parse_args(args) {
256        Ok(options) => options,
257        Err(message) => {
258            eprintln!("error: {message}");
259            print_help();
260            return 2;
261        }
262    };
263
264    // Validate before Cargo work or extension-store mutations. A damaged file
265    // may contain the only record of installed artifacts, so treating it as
266    // an empty registry would make a later write destructive.
267    let records = match installed_native_packages_strict() {
268        Ok(records) => records,
269        Err(error) => {
270            eprintln!(
271                "error: cannot safely install while native package metadata is invalid: {error}"
272            );
273            return 1;
274        }
275    };
276    if !options.build && options.path.is_none() {
277        eprintln!(
278            "error: prebuilt installs use the async dispatcher; Cargo compilation requires --build"
279        );
280        return 2;
281    }
282
283    let temp = match tempfile::tempdir() {
284        Ok(temp) => temp,
285        Err(error) => {
286            eprintln!("error: could not create a temporary Cargo workspace: {error}");
287            return 1;
288        }
289    };
290    let manifest = temp.path().join("Cargo.toml");
291    if let Err(error) = write_manifest(&manifest, &options) {
292        eprintln!("error: could not prepare Cargo workspace: {error}");
293        return 1;
294    }
295
296    if let Err(error) = cargo_command("fetch", &manifest, &options, false) {
297        eprintln!("error: could not resolve `{}`: {error}", options.package);
298        return 1;
299    }
300
301    let metadata = match cargo_metadata(&manifest, &options) {
302        Ok(metadata) => metadata,
303        Err(error) => {
304            eprintln!("error: could not inspect `{}`: {error}", options.package);
305            return 1;
306        }
307    };
308    let package = match metadata
309        .packages
310        .iter()
311        .find(|package| package.name == options.package)
312    {
313        Some(package) => package,
314        None => {
315            eprintln!(
316                "error: Cargo did not resolve a package named `{}`",
317                options.package
318            );
319            return 1;
320        }
321    };
322    match should_install_version(
323        &package.name,
324        records
325            .iter()
326            .find(|p| p.name == package.name)
327            .map(|p| p.version.as_str()),
328        &package.version,
329        options.force,
330    ) {
331        Ok(true) => {}
332        Ok(false) => {
333            print_already_installed(&package.name, &package.version);
334            return 0;
335        }
336        Err(error) => {
337            eprintln!("error: {error}");
338            return 1;
339        }
340    }
341    let cdylib_targets: Vec<&CargoTarget> = package
342        .targets
343        .iter()
344        .filter(|target| target.crate_types.iter().any(|kind| kind == "cdylib"))
345        .collect();
346    if cdylib_targets.is_empty() {
347        eprintln!(
348            "error: `{}` is not an rpi extension crate; it has no `cdylib` target",
349            options.package
350        );
351        eprintln!("{CDYLIB_EXTENSION_HINT}");
352        return 1;
353    }
354
355    if let Err(error) = cargo_command("build", &manifest, &options, true) {
356        eprintln!("error: failed to build `{}`: {error}", options.package);
357        return 1;
358    }
359
360    let artifact_dir = temp.path().join("target").join("release");
361    let artifacts = match find_artifacts(&artifact_dir, &cdylib_targets) {
362        Ok(artifacts) => artifacts,
363        Err(error) => {
364            eprintln!("error: {error}");
365            return 1;
366        }
367    };
368
369    let agent_dir = match crate::config::agent_dir() {
370        Ok(dir) => dir,
371        Err(error) => {
372            eprintln!("error: could not resolve the rpi config directory: {error}");
373            return 1;
374        }
375    };
376    let destination = agent_dir.join("extensions");
377    if let Err(error) = std::fs::create_dir_all(&destination) {
378        eprintln!(
379            "error: could not create extension directory {}: {error}",
380            destination.display()
381        );
382        return 1;
383    }
384
385    let version = metadata
386        .packages
387        .iter()
388        .find(|candidate| candidate.name == options.package)
389        .map(|candidate| candidate.version.clone())
390        .unwrap_or_else(|| "0.0.0".to_string());
391    let record = InstalledNativePackage {
392        name: options.package.clone(),
393        version,
394        source: options
395            .path
396            .as_ref()
397            .map(|path| path.to_string_lossy().into_owned()),
398        prebuilt: None,
399        artifacts: Vec::new(),
400    };
401    let installed = match install_native_package_at(
402        &destination,
403        &agent_dir.join(NATIVE_PACKAGES_FILE),
404        record,
405        &artifacts,
406        options.force,
407    ) {
408        Ok(installed) => installed,
409        Err(error) => {
410            eprintln!("error: could not install `{}`: {error}", options.package);
411            return 1;
412        }
413    };
414    for target in installed {
415        println!("installed {}", target.display());
416    }
417    gc_retired_native_artifacts(&agent_dir);
418    println!("rpi will load this extension on the next start.");
419    0
420}
421
422/// Remove a Rust-native extension installed by `rpi install`.
423///
424/// The install registry is authoritative for new installs. For metadata from
425/// older rpi versions, dynamic libraries whose normalized file stem matches
426/// the crate name are removed as a compatibility fallback.
427pub fn uninstall(args: &[String]) -> i32 {
428    if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
429        print_uninstall_help();
430        return 0;
431    }
432    let name = match parse_uninstall_name(args) {
433        Ok(name) => name,
434        Err(error) => {
435            eprintln!("error: {error}");
436            print_uninstall_help();
437            return 2;
438        }
439    };
440    let agent = match crate::config::agent_dir() {
441        Ok(path) => path,
442        Err(error) => {
443            eprintln!("error: could not resolve the rpi config directory: {error}");
444            return 1;
445        }
446    };
447    let extension_dir = agent.join("extensions");
448    let metadata_path = agent.join(NATIVE_PACKAGES_FILE);
449    let _lock = match NativePackageMutationLock::acquire_exclusive(&metadata_path) {
450        Ok(lock) => lock,
451        Err(error) => {
452            eprintln!("error: cannot safely uninstall: {error}");
453            return 1;
454        }
455    };
456    let records = match read_native_package_registry_at(&metadata_path) {
457        Ok(registry) => registry.into_records(),
458        Err(error) => {
459            eprintln!(
460                "error: cannot safely uninstall while native package metadata is invalid: {error}"
461            );
462            return 1;
463        }
464    };
465    let mut asset_targets = Vec::new();
466    for record in records.iter().filter(|record| record.name == name) {
467        for asset in recorded_asset_paths(record) {
468            match checked_asset_target(&extension_dir, asset) {
469                Ok(path) => asset_targets.push(path),
470                Err(error) => {
471                    eprintln!("error: cannot safely uninstall: {error}");
472                    return 1;
473                }
474            }
475        }
476    }
477    let had_record = records.iter().any(|record| record.name == name);
478    let wanted = normalize_name(&name);
479    let artifact_names: std::collections::HashSet<String> = records
480        .iter()
481        .filter(|record| record.name == name)
482        .flat_map(|record| record.artifacts.iter().cloned())
483        .collect();
484    let use_legacy_name_fallback = artifact_names.is_empty();
485    let mut artifact_targets = Vec::new();
486    if let Ok(entries) = std::fs::read_dir(&extension_dir) {
487        for entry in entries.flatten() {
488            let path = entry.path();
489            let file_name = path
490                .file_name()
491                .map(|name| name.to_string_lossy().into_owned())
492                .unwrap_or_default();
493            let is_artifact = artifact_names.contains(&file_name)
494                || is_versioned_artifact_for_package(&file_name, &wanted)
495                || (use_legacy_name_fallback
496                    && path
497                        .file_stem()
498                        .and_then(|stem| stem.to_str())
499                        .map(|stem| normalize_name(stem.trim_start_matches("lib")) == wanted)
500                        .unwrap_or(false));
501            if is_artifact && is_dynamic_library(&path) {
502                artifact_targets.push(path);
503            }
504        }
505    }
506    if let Err(error) =
507        crate::resource_management::legacy_unowned_targets(&agent, &artifact_targets)
508    {
509        eprintln!("error: {error}");
510        return 1;
511    }
512    let mut remaining: Vec<_> = records
513        .into_iter()
514        .filter(|record| record.name != name)
515        .collect();
516    if had_record {
517        remaining.sort_by(|left, right| left.name.cmp(&right.name));
518    }
519    let mut replacements = Vec::new();
520    let mut removals = artifact_targets.clone();
521    removals.extend(asset_targets.iter().cloned());
522    if had_record {
523        if remaining.is_empty() {
524            removals.push(metadata_path.clone());
525        } else {
526            let registry = match serde_json::to_vec_pretty(&remaining) {
527                Ok(registry) => registry,
528                Err(error) => {
529                    eprintln!("error: could not serialize native package metadata: {error}");
530                    return 1;
531                }
532            };
533            let staged = match stage_native_bytes(&registry, &metadata_path) {
534                Ok(staged) => staged,
535                Err(error) => {
536                    eprintln!("error: could not stage native package metadata: {error}");
537                    return 1;
538                }
539            };
540            replacements.push(NativeReplacement {
541                staged,
542                target: metadata_path.clone(),
543            });
544        }
545    }
546    if let Err(error) = activate_native_transaction(&replacements, &removals, &mut |_, _| Ok(())) {
547        eprintln!("error: could not uninstall Rust extension {name}: {error}");
548        return 1;
549    }
550    prune_empty_dirs(&extension_dir, &asset_targets);
551    for path in artifact_targets.iter().chain(&asset_targets) {
552        println!("removed {}", path.display());
553    }
554    let removed = artifact_targets.len() + asset_targets.len();
555    if removed == 0 && !had_record {
556        println!("Rust extension is not installed: {name}");
557        return 0;
558    }
559    println!("uninstalled Rust extension {name}");
560    0
561}
562
563fn parse_uninstall_name(args: &[String]) -> Result<String, String> {
564    let mut name = None;
565    for arg in args {
566        match arg.as_str() {
567            "--help" | "-h" => return Err("use `rpi uninstall --help` for usage".into()),
568            value if value.starts_with('-') => {
569                return Err(format!("unknown uninstall option `{value}`"))
570            }
571            value => {
572                if name.replace(value.to_string()).is_some() {
573                    return Err("uninstall accepts exactly one crate name".into());
574                }
575            }
576        }
577    }
578    let name = name.ok_or_else(|| "missing crate name".to_string())?;
579    if !valid_package_name(&name) {
580        return Err(format!("invalid Cargo package name `{name}`"));
581    }
582    Ok(name)
583}
584
585#[cfg(test)]
586fn write_native_packages_at(path: &Path, records: &[InstalledNativePackage]) -> Result<(), String> {
587    if records.is_empty() {
588        match std::fs::remove_file(path) {
589            Ok(()) => return Ok(()),
590            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
591            Err(error) => return Err(error.to_string()),
592        }
593    }
594    let parent = path
595        .parent()
596        .ok_or_else(|| "native package metadata has no parent".to_string())?;
597    std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
598    let data = serde_json::to_vec_pretty(records).map_err(|error| error.to_string())?;
599    std::fs::write(path, data).map_err(|error| error.to_string())
600}
601
602/// Read and validate the Rust-native extension registry without hiding errors.
603pub fn read_native_package_registry() -> Result<NativePackageRegistry, String> {
604    let path = crate::config::agent_dir()
605        .map_err(|error| error.to_string())?
606        .join(NATIVE_PACKAGES_FILE);
607    read_native_package_registry_at(&path)
608}
609
610fn read_native_package_registry_at(path: &Path) -> Result<NativePackageRegistry, String> {
611    let data = match std::fs::read(path) {
612        Ok(data) => data,
613        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
614            return Ok(NativePackageRegistry::Missing)
615        }
616        Err(error) => {
617            return Err(format!("could not read {}: {error}", path.display()));
618        }
619    };
620    serde_json::from_slice(&data)
621        .map(NativePackageRegistry::Valid)
622        .map_err(|error| format!("invalid JSON or schema in {}: {error}", path.display()))
623}
624
625/// Read the registry for best-effort discovery and startup update notices.
626///
627/// Missing, unreadable, and malformed registries all produce an empty list so
628/// startup remains safe. Install and uninstall operations instead use
629/// [`read_native_package_registry`] and fail closed on an invalid file.
630pub fn installed_native_packages() -> Vec<InstalledNativePackage> {
631    installed_native_packages_strict().unwrap_or_default()
632}
633
634/// Read the installed package list for a command that may mutate package
635/// state. Unlike startup discovery, callers must surface registry errors and
636/// abort before touching any package store.
637pub(crate) fn installed_native_packages_strict() -> Result<Vec<InstalledNativePackage>, String> {
638    read_native_package_registry().map(NativePackageRegistry::into_records)
639}
640
641/// Resolve the active global native package libraries without scanning the
642/// extension directory. New records name the active files explicitly; older
643/// records had no artifact ledger, so they retain the historical crate-name
644/// scan as a compatibility fallback.
645pub fn active_global_native_package_artifacts() -> Vec<PathBuf> {
646    let Ok(agent_dir) = crate::config::agent_dir() else {
647        return Vec::new();
648    };
649    active_global_native_package_artifacts_at(&agent_dir).unwrap_or_default()
650}
651
652/// Resolve unmanaged legacy libraries from the global extension directory.
653/// Managed versioned artifacts are deliberately omitted: only the active
654/// artifact list above may be loaded for an installed native package.
655pub fn unregistered_global_native_package_artifacts() -> Vec<PathBuf> {
656    let Ok(agent_dir) = crate::config::agent_dir() else {
657        return Vec::new();
658    };
659    unregistered_global_native_package_artifacts_at(&agent_dir).unwrap_or_default()
660}
661
662pub(crate) fn active_global_native_package_artifacts_at(
663    agent_dir: &Path,
664) -> Result<Vec<PathBuf>, String> {
665    let destination = agent_dir.join("extensions");
666    let records =
667        read_native_package_registry_at(&agent_dir.join(NATIVE_PACKAGES_FILE))?.into_records();
668    let mut paths = Vec::new();
669    let mut seen = HashSet::new();
670    for record in records {
671        if record.artifacts.is_empty() {
672            let wanted = normalize_name(&record.name);
673            let Ok(entries) = std::fs::read_dir(&destination) else {
674                continue;
675            };
676            for entry in entries {
677                let entry = entry.map_err(|error| {
678                    format!("could not inspect native extension directory: {error}")
679                })?;
680                let path = entry.path();
681                let matches = path
682                    .file_stem()
683                    .and_then(|stem| stem.to_str())
684                    .map(|stem| normalize_name(stem.trim_start_matches("lib")) == wanted)
685                    .unwrap_or(false);
686                if matches && is_dynamic_library(&path) {
687                    let identity = transaction_path_identity(&path);
688                    if seen.insert(identity) {
689                        paths.push(path);
690                    }
691                }
692            }
693        } else {
694            for artifact in record.artifacts {
695                let path = checked_registered_artifact_path(&destination, &artifact)?;
696                if seen.insert(transaction_path_identity(&path)) {
697                    paths.push(path);
698                }
699            }
700        }
701    }
702    paths.sort();
703    Ok(paths)
704}
705
706pub(crate) fn unregistered_global_native_package_artifacts_at(
707    agent_dir: &Path,
708) -> Result<Vec<PathBuf>, String> {
709    let destination = agent_dir.join("extensions");
710    let records =
711        read_native_package_registry_at(&agent_dir.join(NATIVE_PACKAGES_FILE))?.into_records();
712    let managed = records
713        .iter()
714        .flat_map(|record| record.artifacts.iter())
715        .map(|artifact| native_artifact_identity(artifact))
716        .collect::<HashSet<_>>();
717    let legacy_names = records
718        .iter()
719        .filter(|record| record.artifacts.is_empty())
720        .map(|record| normalize_name(&record.name))
721        .collect::<HashSet<_>>();
722    let mut paths = Vec::new();
723    let Ok(entries) = std::fs::read_dir(&destination) else {
724        return Ok(paths);
725    };
726    for entry in entries {
727        let path = entry
728            .map_err(|error| format!("could not inspect native extension directory: {error}"))?
729            .path();
730        let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
731            continue;
732        };
733        let legacy_managed = path
734            .file_stem()
735            .and_then(|stem| stem.to_str())
736            .map(|stem| legacy_names.contains(&normalize_name(stem.trim_start_matches("lib"))))
737            .unwrap_or(false);
738        if !is_dynamic_library(&path)
739            || managed.contains(&native_artifact_identity(name))
740            || is_versioned_artifact_name(name)
741            || legacy_managed
742        {
743            continue;
744        }
745        paths.push(path);
746    }
747    paths.sort();
748    Ok(paths)
749}
750
751#[cfg(test)]
752fn record_native_package_at(path: &Path, record: &InstalledNativePackage) -> Result<(), String> {
753    let mut records = read_native_package_registry_at(path)?.into_records();
754    if let Some(existing) = records.iter_mut().find(|item| item.name == record.name) {
755        *existing = record.clone();
756    } else {
757        records.push(record.clone());
758    }
759    records.sort_by(|left, right| left.name.cmp(&right.name));
760    write_native_packages_at(path, &records)
761}
762
763struct StagedNativeFile {
764    path: PathBuf,
765}
766
767impl Drop for StagedNativeFile {
768    fn drop(&mut self) {
769        let _ = std::fs::remove_file(&self.path);
770    }
771}
772
773struct NativeReplacement {
774    staged: StagedNativeFile,
775    target: PathBuf,
776}
777
778struct NativeBackup {
779    target: PathBuf,
780    backup: PathBuf,
781}
782
783/// A sidecar file of a prebuilt package, already verified against its archive
784/// index. `path` is the relative store path, such as `runtime/main.mjs`.
785pub(crate) struct NativeAssetFile {
786    pub path: String,
787    pub bytes: Vec<u8>,
788}
789
790pub(crate) fn install_native_package_at(
791    destination: &Path,
792    metadata_path: &Path,
793    record: InstalledNativePackage,
794    artifacts: &[PathBuf],
795    force: bool,
796) -> Result<Vec<PathBuf>, String> {
797    install_native_package_with_assets_at(destination, metadata_path, record, artifacts, &[], force)
798}
799
800/// Install a library and its sidecar assets as one transaction. The returned paths
801/// are the library followed by every asset that was activated.
802pub(crate) fn install_native_package_with_assets_at(
803    destination: &Path,
804    metadata_path: &Path,
805    record: InstalledNativePackage,
806    artifacts: &[PathBuf],
807    assets: &[NativeAssetFile],
808    force: bool,
809) -> Result<Vec<PathBuf>, String> {
810    install_native_package_at_with_hook(
811        destination,
812        metadata_path,
813        record,
814        artifacts,
815        assets,
816        force,
817        |_, _| Ok(()),
818    )
819}
820
821fn install_native_package_at_with_hook(
822    destination: &Path,
823    metadata_path: &Path,
824    mut record: InstalledNativePackage,
825    artifacts: &[PathBuf],
826    assets: &[NativeAssetFile],
827    force: bool,
828    mut before_activate: impl FnMut(usize, &Path) -> Result<(), String>,
829) -> Result<Vec<PathBuf>, String> {
830    std::fs::create_dir_all(destination).map_err(|error| {
831        format!(
832            "could not create extension directory {}: {error}",
833            destination.display()
834        )
835    })?;
836    let metadata_parent = metadata_path
837        .parent()
838        .ok_or_else(|| "native package metadata has no parent".to_string())?;
839    std::fs::create_dir_all(metadata_parent).map_err(|error| error.to_string())?;
840    let _lock = NativePackageMutationLock::acquire(metadata_path)?;
841    let mut records = read_native_package_registry_at(metadata_path)?.into_records();
842    let previous = records
843        .iter()
844        .find(|candidate| candidate.name == record.name)
845        .cloned();
846
847    if !should_install_version(
848        &record.name,
849        previous.as_ref().map(|p| p.version.as_str()),
850        &record.version,
851        force,
852    )? {
853        print_already_installed(&record.name, &record.version);
854        return Ok(Vec::new());
855    }
856
857    let mut source_artifacts = Vec::with_capacity(artifacts.len());
858    let mut source_keys = HashSet::new();
859    for artifact in artifacts {
860        let metadata = std::fs::symlink_metadata(artifact).map_err(|error| {
861            format!(
862                "could not inspect built artifact {}: {error}",
863                artifact.display()
864            )
865        })?;
866        if !metadata.is_file() || metadata.file_type().is_symlink() {
867            return Err(format!(
868                "built artifact is not a regular file: {}",
869                artifact.display()
870            ));
871        }
872        let name = artifact_file_name(artifact)?;
873        if !source_keys.insert(native_artifact_identity(&name)) {
874            return Err(format!("duplicate built artifact name `{name}`"));
875        }
876        source_artifacts.push((name, artifact.clone()));
877    }
878    if source_artifacts.is_empty() {
879        return Err("Cargo produced no installable cdylib artifacts".to_string());
880    }
881
882    // Upgrades get a fresh, version-bearing filename, so an already loaded
883    // DLL/SO/DYLIB is never replaced or unlinked. First installs retain the
884    // historical stable name for compatibility; later upgrades switch to the
885    // immutable versioned name.
886    let mut new_artifacts = Vec::with_capacity(source_artifacts.len());
887    let mut new_keys = HashSet::new();
888    for (name, source) in source_artifacts {
889        let target_name = if previous.is_some() || (force && destination.join(&name).exists()) {
890            versioned_artifact_name(&name, &record.version, destination)?
891        } else {
892            name
893        };
894        if !new_keys.insert(native_artifact_identity(&target_name)) {
895            return Err(format!("duplicate versioned artifact name `{target_name}`"));
896        }
897        new_artifacts.push((target_name, source));
898    }
899
900    for other in records
901        .iter()
902        .filter(|candidate| candidate.name != record.name)
903    {
904        for owned in &other.artifacts {
905            checked_registered_artifact_path(destination, owned)?;
906            if new_keys.contains(&native_artifact_identity(owned)) {
907                return Err(format!(
908                    "artifact `{owned}` is already owned by installed package `{}`",
909                    other.name
910                ));
911            }
912        }
913    }
914
915    // Sidecars share the store namespace with every package's artifacts and assets.
916    validate_new_assets(&record, assets, &new_artifacts)?;
917    for other in records
918        .iter()
919        .filter(|candidate| candidate.name != record.name)
920    {
921        for owned in recorded_asset_paths(other) {
922            let clash = new_artifacts
923                .iter()
924                .map(|(name, _)| name.as_str())
925                .chain(assets.iter().map(|asset| asset.path.as_str()))
926                .find(|new| asset_paths_conflict(new, owned));
927            if let Some(new) = clash {
928                return Err(format!(
929                    "`{new}` conflicts with asset `{owned}` owned by installed package `{}`",
930                    other.name
931                ));
932            }
933        }
934    }
935
936    let installed_targets = new_artifacts
937        .iter()
938        .map(|(name, _)| destination.join(name))
939        .collect::<Vec<_>>();
940    crate::resource_management::legacy_unowned_targets(metadata_parent, &installed_targets)?;
941    for target in &installed_targets {
942        match std::fs::symlink_metadata(target) {
943            Ok(_) => {
944                return Err(format!(
945                    "versioned extension target {} already exists",
946                    target.display()
947                ))
948            }
949            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
950            Err(error) => {
951                return Err(format!(
952                    "could not inspect extension target {}: {error}",
953                    target.display()
954                ))
955            }
956        }
957    }
958
959    let previous_assets: Vec<String> = previous
960        .as_ref()
961        .map(|package| {
962            recorded_asset_paths(package)
963                .into_iter()
964                .map(str::to_owned)
965                .collect()
966        })
967        .unwrap_or_default();
968    let mut asset_targets = Vec::with_capacity(assets.len());
969    for asset in assets {
970        let target = checked_asset_target(destination, &asset.path)?;
971        // Same identity rule as the transaction and the stale check. A case-folded name is
972        // owned only when it is the recorded file itself (a case-insensitive store), never a
973        // different file that merely differs in case.
974        let owned = previous_assets.iter().any(|old| {
975            native_artifact_identity(old) == native_artifact_identity(&asset.path)
976                || (old.eq_ignore_ascii_case(&asset.path)
977                    && same_file(&destination.join(old), &target))
978        });
979        if !force && !owned {
980            match std::fs::symlink_metadata(&target) {
981                Ok(_) => {
982                    return Err(format!(
983                        "extension {} already exists; use --force to replace it",
984                        target.display()
985                    ))
986                }
987                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
988                Err(error) => {
989                    return Err(format!(
990                        "could not inspect extension target {}: {error}",
991                        target.display()
992                    ))
993                }
994            }
995        }
996        asset_targets.push(target);
997    }
998    let mut stale_asset_targets = Vec::new();
999    for old in &previous_assets {
1000        // On a case-sensitive store a case-only rename is a different file, and leaving
1001        // the old one would orphan it. Windows folds identities, as the transaction does.
1002        if !assets
1003            .iter()
1004            .any(|asset| native_artifact_identity(&asset.path) == native_artifact_identity(old))
1005        {
1006            stale_asset_targets.push(checked_asset_target(destination, old)?);
1007        }
1008    }
1009
1010    record.artifacts = new_artifacts.iter().map(|(name, _)| name.clone()).collect();
1011    if let Some(existing) = records
1012        .iter_mut()
1013        .find(|candidate| candidate.name == record.name)
1014    {
1015        *existing = record;
1016    } else {
1017        records.push(record);
1018    }
1019    records.sort_by(|left, right| left.name.cmp(&right.name));
1020
1021    let mut replacements = Vec::with_capacity(new_artifacts.len() + assets.len() + 1);
1022    for ((_, source), target) in new_artifacts.iter().zip(&installed_targets) {
1023        replacements.push(NativeReplacement {
1024            staged: stage_native_artifact(source, target)?,
1025            target: target.clone(),
1026        });
1027    }
1028    // Sidecars are written into a private directory on the store's filesystem, so
1029    // the transaction can rename each one into place. Nothing reaches the store
1030    // until every replacement has been staged.
1031    let stage = if assets.is_empty() {
1032        None
1033    } else {
1034        Some(StagedDirectory::create(destination)?)
1035    };
1036    if let Some(stage) = &stage {
1037        for (asset, target) in assets.iter().zip(&asset_targets) {
1038            let staged = stage.path.join(&asset.path);
1039            if let Some(parent) = staged.parent() {
1040                std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
1041            }
1042            write_staged_asset(&asset.bytes, &staged)?;
1043            replacements.push(NativeReplacement {
1044                staged: StagedNativeFile { path: staged },
1045                target: target.clone(),
1046            });
1047        }
1048    }
1049    let registry = serde_json::to_vec_pretty(&records).map_err(|error| error.to_string())?;
1050    replacements.push(NativeReplacement {
1051        staged: stage_native_bytes(&registry, metadata_path)?,
1052        target: metadata_path.to_path_buf(),
1053    });
1054    // Directories this transaction creates, so a failed install can remove them again.
1055    let mut created_dirs = Vec::new();
1056    for target in &asset_targets {
1057        if let Some(parent) = target.parent() {
1058            created_dirs.extend(
1059                parent
1060                    .ancestors()
1061                    .take_while(|dir| *dir != destination && !dir.exists())
1062                    .map(Path::to_path_buf),
1063            );
1064            if let Err(error) = std::fs::create_dir_all(parent) {
1065                remove_created_dirs(&mut created_dirs);
1066                return Err(format!(
1067                    "could not create extension directory {}: {error}",
1068                    parent.display()
1069                ));
1070            }
1071        }
1072    }
1073
1074    // Dynamic-library artifacts are intentionally retained across upgrades.
1075    // Only replaceable sidecar files follow the old cleanup behavior.
1076    let mut removals = Vec::new();
1077    removals.extend(stale_asset_targets.iter().cloned());
1078    if let Err(error) = activate_native_transaction(&replacements, &removals, &mut before_activate)
1079    {
1080        remove_created_dirs(&mut created_dirs);
1081        return Err(error);
1082    }
1083    prune_empty_dirs(destination, &stale_asset_targets);
1084    Ok(installed_targets.into_iter().chain(asset_targets).collect())
1085}
1086
1087/// Assets must match the registry ledger exactly, and each must be a valid,
1088/// non-overlapping store path that no library artifact of this package occupies.
1089fn validate_new_assets(
1090    record: &InstalledNativePackage,
1091    assets: &[NativeAssetFile],
1092    artifacts: &[(String, PathBuf)],
1093) -> Result<(), String> {
1094    let declared: Vec<&str> = assets.iter().map(|asset| asset.path.as_str()).collect();
1095    if recorded_asset_paths(record) != declared {
1096        return Err("internal error: sidecar ledger does not match the staged assets".into());
1097    }
1098    for (index, asset) in assets.iter().enumerate() {
1099        validate_asset_path(&asset.path)?;
1100        if assets[..index]
1101            .iter()
1102            .any(|other| asset_paths_conflict(&other.path, &asset.path))
1103        {
1104            return Err(format!(
1105                "duplicate or overlapping asset path `{}`",
1106                asset.path
1107            ));
1108        }
1109        if artifacts
1110            .iter()
1111            .any(|(name, _)| asset_paths_conflict(name, &asset.path))
1112        {
1113            return Err(format!(
1114                "asset `{}` collides with a library artifact",
1115                asset.path
1116            ));
1117        }
1118    }
1119    Ok(())
1120}
1121
1122fn recorded_asset_paths(package: &InstalledNativePackage) -> Vec<&str> {
1123    package
1124        .prebuilt
1125        .iter()
1126        .flat_map(|prebuilt| prebuilt.assets.iter().map(|asset| asset.path.as_str()))
1127        .collect()
1128}
1129
1130/// Resolve a declared sidecar under the store without following links. Existing
1131/// ancestors must be real directories; missing ones are created by the transaction.
1132fn checked_asset_target(destination: &Path, relative: &str) -> Result<PathBuf, String> {
1133    validate_asset_path(relative)?;
1134    let parts: Vec<&str> = relative.split('/').collect();
1135    let mut current = destination.to_path_buf();
1136    for part in &parts[..parts.len() - 1] {
1137        current.push(part);
1138        match std::fs::symlink_metadata(&current) {
1139            Ok(metadata) if metadata.is_dir() => {}
1140            Ok(_) => {
1141                return Err(format!(
1142                    "asset parent {} is not a plain directory",
1143                    current.display()
1144                ))
1145            }
1146            Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
1147            Err(error) => {
1148                return Err(format!(
1149                    "could not inspect asset parent {}: {error}",
1150                    current.display()
1151                ))
1152            }
1153        }
1154    }
1155    Ok(destination.join(relative))
1156}
1157
1158/// A private staging directory, removed on every exit path.
1159struct StagedDirectory {
1160    path: PathBuf,
1161}
1162
1163impl StagedDirectory {
1164    fn create(destination: &Path) -> Result<Self, String> {
1165        let path = destination.join(format!(
1166            ".rpi-assets-stage-{}",
1167            uuid::Uuid::new_v4().simple()
1168        ));
1169        std::fs::create_dir(&path).map_err(|error| {
1170            format!(
1171                "could not create asset staging directory {}: {error}",
1172                path.display()
1173            )
1174        })?;
1175        Ok(Self { path })
1176    }
1177}
1178
1179impl Drop for StagedDirectory {
1180    fn drop(&mut self) {
1181        let _ = std::fs::remove_dir_all(&self.path);
1182    }
1183}
1184
1185fn write_staged_asset(bytes: &[u8], path: &Path) -> Result<(), String> {
1186    let mut file = std::fs::OpenOptions::new()
1187        .write(true)
1188        .create_new(true)
1189        .open(path)
1190        .map_err(|error| format!("could not create staged asset {}: {error}", path.display()))?;
1191    file.write_all(bytes)
1192        .and_then(|()| file.sync_all())
1193        .map_err(|error| format!("could not write staged asset {}: {error}", path.display()))
1194}
1195
1196/// Remove directories a failed transaction created, deepest first. A directory that is
1197/// not empty (for example after a failed rollback) is kept.
1198fn remove_created_dirs(dirs: &mut [PathBuf]) {
1199    dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
1200    for dir in dirs.iter() {
1201        let _ = std::fs::remove_dir(dir);
1202    }
1203}
1204
1205/// Remove directories left empty by removed sidecars. The store itself is kept.
1206fn prune_empty_dirs(root: &Path, removed: &[PathBuf]) {
1207    for path in removed {
1208        let mut current = path.parent();
1209        while let Some(dir) = current {
1210            if dir == root || !dir.starts_with(root) || std::fs::remove_dir(dir).is_err() {
1211                break;
1212            }
1213            current = dir.parent();
1214        }
1215    }
1216}
1217
1218fn is_versioned_artifact_name(name: &str) -> bool {
1219    let Some(stem) = Path::new(name).file_stem().and_then(|value| value.to_str()) else {
1220        return false;
1221    };
1222    let Some((prefix, token)) = stem.rsplit_once('-') else {
1223        return false;
1224    };
1225    prefix.rsplit_once("-v").is_some()
1226        && token.len() == 32
1227        && token.bytes().all(|value| value.is_ascii_hexdigit())
1228}
1229
1230fn is_versioned_artifact_for_package(name: &str, normalized_package: &str) -> bool {
1231    let Some(stem) = Path::new(name).file_stem().and_then(|value| value.to_str()) else {
1232        return false;
1233    };
1234    let Some((prefix, _)) = stem.rsplit_once("-v") else {
1235        return false;
1236    };
1237    is_versioned_artifact_name(name)
1238        && normalize_name(prefix.trim_start_matches("lib")) == normalized_package
1239}
1240
1241/// Best-effort cleanup for retired native artifacts. The exclusive resource
1242/// lease is the proof that no host is currently holding the old libraries; a
1243/// live host therefore only causes cleanup to be deferred until a later
1244/// install or explicit maintenance command.
1245fn gc_retired_native_artifacts(agent_dir: &Path) {
1246    let metadata_path = agent_dir.join(NATIVE_PACKAGES_FILE);
1247    let lease = match crate::resource_management::mutation_lease(agent_dir) {
1248        Ok(lease) => lease,
1249        Err(_) => return,
1250    };
1251    let _lock = match NativePackageMutationLock::acquire_with_lease(&metadata_path, lease) {
1252        Ok(lock) => lock,
1253        Err(_) => return,
1254    };
1255    let records = match read_native_package_registry_at(&metadata_path) {
1256        Ok(registry) => registry.into_records(),
1257        Err(_) => return,
1258    };
1259    let active = records
1260        .iter()
1261        .flat_map(|record| record.artifacts.iter())
1262        .map(|artifact| native_artifact_identity(artifact))
1263        .collect::<HashSet<_>>();
1264    let destination = agent_dir.join("extensions");
1265    let Ok(entries) = std::fs::read_dir(&destination) else {
1266        return;
1267    };
1268    for entry in entries.flatten() {
1269        let path = entry.path();
1270        let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
1271            continue;
1272        };
1273        if !is_dynamic_library(&path)
1274            || !is_versioned_artifact_name(name)
1275            || active.contains(&native_artifact_identity(name))
1276        {
1277            continue;
1278        }
1279        if crate::resource_management::legacy_unowned_targets(
1280            agent_dir,
1281            std::slice::from_ref(&path),
1282        )
1283        .is_err()
1284        {
1285            continue;
1286        }
1287        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
1288            continue;
1289        };
1290        if metadata.file_type().is_file() {
1291            let _ = std::fs::remove_file(path);
1292        }
1293    }
1294}
1295
1296pub(crate) fn versioned_artifact_name(
1297    original: &str,
1298    version: &str,
1299    destination: &Path,
1300) -> Result<String, String> {
1301    let path = Path::new(original);
1302    let stem = path
1303        .file_stem()
1304        .and_then(|value| value.to_str())
1305        .ok_or_else(|| format!("artifact has no UTF-8 file stem: {original}"))?;
1306    let extension = path
1307        .extension()
1308        .and_then(|value| value.to_str())
1309        .ok_or_else(|| format!("artifact has no dynamic-library extension: {original}"))?;
1310    let version = version
1311        .chars()
1312        .map(|value| {
1313            if value.is_ascii_alphanumeric() || matches!(value, '.' | '-' | '_') {
1314                value
1315            } else {
1316                '_'
1317            }
1318        })
1319        .collect::<String>();
1320    let version = if version.is_empty() {
1321        "unknown"
1322    } else {
1323        &version
1324    };
1325    for _ in 0..8 {
1326        let name = format!(
1327            "{stem}-v{version}-{}.{}",
1328            uuid::Uuid::new_v4().simple(),
1329            extension
1330        );
1331        let target = destination.join(&name);
1332        if matches!(std::fs::symlink_metadata(&target), Err(error) if error.kind() == std::io::ErrorKind::NotFound)
1333        {
1334            return Ok(name);
1335        }
1336    }
1337    Err(format!(
1338        "could not allocate a unique versioned artifact for {original}"
1339    ))
1340}
1341
1342fn artifact_file_name(path: &Path) -> Result<String, String> {
1343    let name = path
1344        .file_name()
1345        .and_then(|name| name.to_str())
1346        .ok_or_else(|| format!("artifact has no UTF-8 file name: {}", path.display()))?;
1347    checked_registered_artifact_path(Path::new("."), name)?;
1348    Ok(name.to_string())
1349}
1350
1351fn checked_registered_artifact_path(destination: &Path, name: &str) -> Result<PathBuf, String> {
1352    let relative = Path::new(name);
1353    let is_single_component = relative.components().count() == 1
1354        && relative.file_name().and_then(|value| value.to_str()) == Some(name);
1355    if !is_single_component || !is_dynamic_library(relative) {
1356        return Err(format!("unsafe native package artifact name `{name}`"));
1357    }
1358    Ok(destination.join(relative))
1359}
1360
1361fn native_artifact_identity(name: &str) -> String {
1362    if cfg!(windows) {
1363        name.to_ascii_lowercase()
1364    } else {
1365        name.to_string()
1366    }
1367}
1368
1369/// Whether two paths name the same existing file (one inode). Windows already folds case
1370/// in `native_artifact_identity`, so only Unix needs the inode check.
1371#[cfg(unix)]
1372fn same_file(left: &Path, right: &Path) -> bool {
1373    use std::os::unix::fs::MetadataExt;
1374    match (
1375        std::fs::symlink_metadata(left),
1376        std::fs::symlink_metadata(right),
1377    ) {
1378        (Ok(left), Ok(right)) => left.dev() == right.dev() && left.ino() == right.ino(),
1379        _ => false,
1380    }
1381}
1382
1383#[cfg(not(unix))]
1384fn same_file(_: &Path, _: &Path) -> bool {
1385    false
1386}
1387
1388fn transaction_path_identity(path: &Path) -> String {
1389    let value = path.to_string_lossy().into_owned();
1390    if cfg!(windows) {
1391        value.to_ascii_lowercase()
1392    } else {
1393        value
1394    }
1395}
1396
1397fn adjacent_transaction_path(target: &Path, kind: &str) -> Result<PathBuf, String> {
1398    let parent = target
1399        .parent()
1400        .ok_or_else(|| format!("transaction target has no parent: {}", target.display()))?;
1401    let leaf = target
1402        .file_name()
1403        .and_then(|name| name.to_str())
1404        .unwrap_or("artifact");
1405    for _ in 0..8 {
1406        let candidate = parent.join(format!(
1407            ".{leaf}.rpi-{kind}-{}",
1408            uuid::Uuid::new_v4().simple()
1409        ));
1410        match std::fs::symlink_metadata(&candidate) {
1411            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(candidate),
1412            Ok(_) => {}
1413            Err(error) => {
1414                return Err(format!(
1415                    "could not inspect transaction path {}: {error}",
1416                    candidate.display()
1417                ))
1418            }
1419        }
1420    }
1421    Err(format!(
1422        "could not allocate a transaction path beside {}",
1423        target.display()
1424    ))
1425}
1426
1427fn stage_native_artifact(source: &Path, target: &Path) -> Result<StagedNativeFile, String> {
1428    let stage = adjacent_transaction_path(target, "stage")?;
1429    let result = (|| {
1430        let source_metadata = std::fs::symlink_metadata(source).map_err(|error| {
1431            format!(
1432                "could not inspect built artifact {}: {error}",
1433                source.display()
1434            )
1435        })?;
1436        let mut input = std::fs::File::open(source).map_err(|error| {
1437            format!(
1438                "could not open built artifact {}: {error}",
1439                source.display()
1440            )
1441        })?;
1442        let mut output = std::fs::OpenOptions::new()
1443            .write(true)
1444            .create_new(true)
1445            .open(&stage)
1446            .map_err(|error| {
1447                format!(
1448                    "could not create staged artifact {}: {error}",
1449                    stage.display()
1450                )
1451            })?;
1452        std::io::copy(&mut input, &mut output)
1453            .and_then(|_| output.sync_all())
1454            .map_err(|error| {
1455                format!(
1456                    "could not write staged artifact {} beside {}: {error}",
1457                    stage.display(),
1458                    target.display()
1459                )
1460            })?;
1461        drop(output);
1462        std::fs::set_permissions(&stage, source_metadata.permissions()).map_err(|error| {
1463            format!(
1464                "could not preserve permissions on staged artifact {}: {error}",
1465                stage.display()
1466            )
1467        })
1468    })();
1469    if let Err(error) = result {
1470        let _ = std::fs::remove_file(&stage);
1471        return Err(error);
1472    }
1473    Ok(StagedNativeFile { path: stage })
1474}
1475fn stage_native_bytes(bytes: &[u8], target: &Path) -> Result<StagedNativeFile, String> {
1476    let stage = adjacent_transaction_path(target, "stage")?;
1477    let result = (|| {
1478        let mut file = std::fs::OpenOptions::new()
1479            .write(true)
1480            .create_new(true)
1481            .open(&stage)
1482            .map_err(|error| {
1483                format!(
1484                    "could not create staged registry {}: {error}",
1485                    stage.display()
1486                )
1487            })?;
1488        file.write_all(bytes)
1489            .and_then(|()| file.sync_all())
1490            .map_err(|error| {
1491                format!(
1492                    "could not flush staged registry {}: {error}",
1493                    stage.display()
1494                )
1495            })
1496    })();
1497    if let Err(error) = result {
1498        let _ = std::fs::remove_file(&stage);
1499        return Err(error);
1500    }
1501    Ok(StagedNativeFile { path: stage })
1502}
1503
1504fn activate_native_transaction(
1505    replacements: &[NativeReplacement],
1506    removals: &[PathBuf],
1507    before_activate: &mut impl FnMut(usize, &Path) -> Result<(), String>,
1508) -> Result<(), String> {
1509    let mut affected = Vec::new();
1510    let mut seen = HashSet::new();
1511    for target in replacements
1512        .iter()
1513        .map(|replacement| &replacement.target)
1514        .chain(removals.iter())
1515    {
1516        if seen.insert(transaction_path_identity(target)) {
1517            affected.push(target.clone());
1518        }
1519    }
1520
1521    for target in &affected {
1522        match std::fs::symlink_metadata(target) {
1523            Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {}
1524            Ok(_) => {
1525                return Err(format!(
1526                    "refusing to replace non-regular native package file {}",
1527                    target.display()
1528                ))
1529            }
1530            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1531            Err(error) => {
1532                return Err(format!(
1533                    "could not inspect native package file {}: {error}",
1534                    target.display()
1535                ))
1536            }
1537        }
1538    }
1539
1540    let mut backups = Vec::new();
1541    for target in &affected {
1542        if !target.exists() {
1543            continue;
1544        }
1545        let backup = match adjacent_transaction_path(target, "backup") {
1546            Ok(backup) => backup,
1547            Err(error) => {
1548                let rollback = rollback_native_transaction(&[], &backups);
1549                return Err(transaction_error(error, rollback));
1550            }
1551        };
1552        if let Err(error) = std::fs::rename(target, &backup) {
1553            let rollback = rollback_native_transaction(&[], &backups);
1554            return Err(transaction_error(
1555                format!("could not back up {}: {error}", target.display()),
1556                rollback,
1557            ));
1558        }
1559        backups.push(NativeBackup {
1560            target: target.clone(),
1561            backup,
1562        });
1563    }
1564
1565    let mut activated = Vec::new();
1566    for (index, replacement) in replacements.iter().enumerate() {
1567        let activation = before_activate(index, &replacement.target).and_then(|()| {
1568            std::fs::rename(&replacement.staged.path, &replacement.target).map_err(|error| {
1569                format!(
1570                    "could not activate replacement {}: {error}",
1571                    replacement.target.display()
1572                )
1573            })
1574        });
1575        if let Err(error) = activation {
1576            let rollback = rollback_native_transaction(&activated, &backups);
1577            return Err(transaction_error(error, rollback));
1578        }
1579        activated.push(replacement.target.clone());
1580    }
1581
1582    for backup in backups {
1583        if let Err(error) = std::fs::remove_file(&backup.backup) {
1584            eprintln!(
1585                "warning: native package updated but backup {} could not be removed: {error}",
1586                backup.backup.display()
1587            );
1588        }
1589    }
1590    Ok(())
1591}
1592
1593fn rollback_native_transaction(
1594    activated: &[PathBuf],
1595    backups: &[NativeBackup],
1596) -> Result<(), String> {
1597    let mut errors = Vec::new();
1598    for target in activated.iter().rev() {
1599        match std::fs::remove_file(target) {
1600            Ok(()) => {}
1601            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1602            Err(error) => errors.push(format!("could not remove {}: {error}", target.display())),
1603        }
1604    }
1605    for backup in backups.iter().rev() {
1606        if let Err(error) = std::fs::rename(&backup.backup, &backup.target) {
1607            errors.push(format!(
1608                "could not restore {} from {}: {error}",
1609                backup.target.display(),
1610                backup.backup.display()
1611            ));
1612        }
1613    }
1614    if errors.is_empty() {
1615        Ok(())
1616    } else {
1617        Err(errors.join("; "))
1618    }
1619}
1620
1621fn transaction_error(error: String, rollback: Result<(), String>) -> String {
1622    match rollback {
1623        Ok(()) => error,
1624        Err(rollback) => format!("{error}; rollback also failed: {rollback}"),
1625    }
1626}
1627
1628fn parse_args(args: &[String]) -> Result<InstallOptions, String> {
1629    let mut package = None;
1630    let mut version = None;
1631    let mut path = None;
1632    let mut locked = false;
1633    let mut force = false;
1634    let mut build = false;
1635    let mut repository = None;
1636    let mut cdn = None;
1637    let mut i = 0;
1638    while i < args.len() {
1639        match args[i].as_str() {
1640            "--help" | "-h" => return Err(help_requested().to_string()),
1641            "--offline" => {} // app normalizes this flag before dispatch
1642            "--locked" => locked = true,
1643            "--force" | "-f" => force = true,
1644            "--build" => build = true,
1645            "--github" => {
1646                i += 1;
1647                repository = Some(value(args, i, "--github")?);
1648            }
1649            "--cdn" => {
1650                i += 1;
1651                cdn = Some(value(args, i, "--cdn")?);
1652            }
1653            "--version" | "-V" => {
1654                i += 1;
1655                version = Some(value(args, i, "--version")?);
1656            }
1657            "--path" => {
1658                i += 1;
1659                path = Some(PathBuf::from(value(args, i, "--path")?));
1660            }
1661            value if value.starts_with('-') => {
1662                return Err(format!("unknown install option `{value}`"));
1663            }
1664            value => {
1665                if package.replace(value.to_string()).is_some() {
1666                    return Err("install accepts exactly one crate name".to_string());
1667                }
1668            }
1669        }
1670        i += 1;
1671    }
1672    if path.is_some() && version.is_some() {
1673        return Err("--path and --version cannot be used together".to_string());
1674    }
1675    if repository.is_some() && (build || path.is_some()) {
1676        return Err("--github cannot be combined with --build or --path".into());
1677    }
1678    if locked && !build && path.is_none() {
1679        return Err("--locked requires --build or --path".into());
1680    }
1681    let package = match package {
1682        Some(package) => package,
1683        None => {
1684            let path = path
1685                .as_deref()
1686                .ok_or_else(|| "missing crate name (or use --path <directory>)".to_string())?;
1687            package_name_from_manifest(path)?
1688        }
1689    };
1690    if !valid_package_name(&package) {
1691        return Err(format!("invalid Cargo package name `{package}`"));
1692    }
1693    let cdn_base = if build || path.is_some() {
1694        None
1695    } else {
1696        crate::cdn::resolve_base(cdn.as_deref())?
1697    };
1698    Ok(InstallOptions {
1699        package,
1700        version,
1701        path,
1702        locked,
1703        force,
1704        build,
1705        repository,
1706        cdn_base,
1707    })
1708}
1709
1710fn value(args: &[String], index: usize, flag: &str) -> Result<String, String> {
1711    args.get(index)
1712        .filter(|value| !value.starts_with('-'))
1713        .cloned()
1714        .ok_or_else(|| format!("{flag} requires a value"))
1715}
1716
1717fn package_name_from_manifest(path: &Path) -> Result<String, String> {
1718    let manifest = if path.is_dir() {
1719        path.join("Cargo.toml")
1720    } else {
1721        path.to_path_buf()
1722    };
1723    let output = Command::new("cargo")
1724        .args([
1725            "metadata",
1726            "--no-deps",
1727            "--format-version",
1728            "1",
1729            "--manifest-path",
1730        ])
1731        .arg(&manifest)
1732        .output()
1733        .map_err(|error| format!("could not run cargo metadata: {error}"))?;
1734    if !output.status.success() {
1735        return Err(format!(
1736            "could not inspect {}: {}",
1737            manifest.display(),
1738            String::from_utf8_lossy(&output.stderr).trim()
1739        ));
1740    }
1741    let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)
1742        .map_err(|error| format!("cargo metadata returned invalid JSON: {error}"))?;
1743    let manifest = std::fs::canonicalize(&manifest)
1744        .map_err(|error| format!("could not resolve {}: {error}", manifest.display()))?;
1745    metadata
1746        .get("packages")
1747        .and_then(serde_json::Value::as_array)
1748        .and_then(|packages| {
1749            packages.iter().find_map(|package| {
1750                let package_manifest = package.get("manifest_path")?.as_str()?;
1751                let package_manifest = std::fs::canonicalize(package_manifest).ok()?;
1752                (package_manifest == manifest)
1753                    .then(|| package.get("name")?.as_str().map(str::to_owned))
1754                    .flatten()
1755            })
1756        })
1757        .filter(|name| valid_package_name(name))
1758        .ok_or_else(|| format!("{} has no valid [package].name", manifest.display()))
1759}
1760
1761fn valid_package_name(name: &str) -> bool {
1762    !name.is_empty()
1763        && name
1764            .bytes()
1765            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1766}
1767
1768fn help_requested() -> &'static str {
1769    "use `rpi install --help` for usage"
1770}
1771
1772pub fn print_help() {
1773    println!("Usage: rpi install [<crate>] [options]\n\nInstall or upgrade a prebuilt Rust extension using the versioned rpi-extension.json protocol.\nRepository and latest stable version are resolved from crates.io metadata.\nNewer versions upgrade automatically; an already installed version is skipped.\n\nOptions:\n  --version <version>  Install an exact package version\n  --github <owner/repo> Use this GitHub repository; default version comes from package-specific tags\n  --cdn <URL>           Download release archives from this HTTPS CDN; manifests remain on GitHub\n  --build              Explicitly compile the extension with Cargo\n  --path <directory>   Build a local extension crate; crate name may be omitted\n  --locked             Require Cargo.lock to remain unchanged (with --build/--path)\n  --force, -f          Reinstall the same version or allow a downgrade/replacement\n  --help, -h           Show this help\n\nExamples:\n  rpi install rpi-todo\n  rpi install rpi-todo --version 0.2.0\n  rpi install my-extension --github owner/repo --version 1.2.3\n  rpi install rpi-todo --build\n  rpi install --path . --force\n\nA missing or incompatible prebuilt release is an error; Cargo compilation requires --build.");
1774}
1775
1776fn print_uninstall_help() {
1777    println!(
1778        "Usage: rpi uninstall <crate>\n\nRemove a Rust cdylib extension installed by `rpi install`.\n\nOptions:\n  --help, -h           Show this help\n\nExample:\n  rpi uninstall rpi-extension-example"
1779    );
1780}
1781
1782fn write_manifest(path: &Path, options: &InstallOptions) -> Result<(), String> {
1783    let source_dir = path
1784        .parent()
1785        .ok_or_else(|| "temporary workspace has no parent directory".to_string())?
1786        .join("src");
1787    std::fs::create_dir_all(&source_dir).map_err(|error| error.to_string())?;
1788    // Cargo requires the temporary root package to have a target even though
1789    // rpi never builds it; the requested extension is built as a dependency.
1790    std::fs::write(source_dir.join("lib.rs"), "pub fn installer_marker() {}\n")
1791        .map_err(|error| error.to_string())?;
1792    let dependency = if let Some(local_path) = &options.path {
1793        let absolute = if local_path.is_absolute() {
1794            local_path.clone()
1795        } else {
1796            std::env::current_dir()
1797                .map_err(|error| error.to_string())?
1798                .join(local_path)
1799        };
1800        format!(
1801            "rpi_extension_dep = {{ package = {:?}, path = {:?} }}",
1802            options.package,
1803            absolute.display().to_string()
1804        )
1805    } else {
1806        let version = options.version.as_deref().unwrap_or("*");
1807        format!(
1808            "rpi_extension_dep = {{ package = {:?}, version = {:?} }}",
1809            options.package, version
1810        )
1811    };
1812    let contents = format!(
1813        "[package]\nname = \"{INSTALLER_MANIFEST}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n\n[dependencies]\n{dependency}\n"
1814    );
1815    std::fs::write(path, contents).map_err(|error| error.to_string())
1816}
1817
1818fn cargo_command(
1819    subcommand: &str,
1820    manifest: &Path,
1821    options: &InstallOptions,
1822    build: bool,
1823) -> Result<(), String> {
1824    let mut command = Command::new("cargo");
1825    command.arg(subcommand).arg("--manifest-path").arg(manifest);
1826    if build {
1827        command
1828            .arg("--package")
1829            .arg(&options.package)
1830            .arg("--release")
1831            .arg("--target-dir")
1832            .arg(manifest.parent().unwrap().join("target"));
1833    }
1834    if options.locked {
1835        command.arg("--locked");
1836    }
1837    if crate::args::offline_env_enabled() {
1838        command.arg("--offline");
1839    }
1840    let status = command
1841        .stdin(Stdio::inherit())
1842        .stdout(Stdio::inherit())
1843        .stderr(Stdio::inherit())
1844        .status()
1845        .map_err(|error| format!("could not execute cargo: {error}"))?;
1846    if status.success() {
1847        Ok(())
1848    } else {
1849        Err(format!("cargo {subcommand} exited with {status}"))
1850    }
1851}
1852
1853fn cargo_metadata(manifest: &Path, options: &InstallOptions) -> Result<CargoMetadata, String> {
1854    let mut command = Command::new("cargo");
1855    command
1856        .arg("metadata")
1857        .arg("--format-version")
1858        .arg("1")
1859        .arg("--manifest-path")
1860        .arg(manifest);
1861    if options.locked {
1862        command.arg("--locked");
1863    }
1864    if crate::args::offline_env_enabled() {
1865        command.arg("--offline");
1866    }
1867    let output = command
1868        .output()
1869        .map_err(|error| format!("could not execute cargo: {error}"))?;
1870    if !output.status.success() {
1871        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
1872    }
1873    serde_json::from_slice(&output.stdout)
1874        .map_err(|error| format!("invalid cargo metadata: {error}"))
1875}
1876
1877fn find_artifacts(release_dir: &Path, targets: &[&CargoTarget]) -> Result<Vec<PathBuf>, String> {
1878    let mut artifacts = Vec::new();
1879    for target in targets {
1880        let wanted = normalize_name(&target.name);
1881        let mut matches = Vec::new();
1882        for dir in [release_dir.to_path_buf(), release_dir.join("deps")] {
1883            let entries = std::fs::read_dir(&dir).map_err(|error| {
1884                format!("could not inspect build output {}: {error}", dir.display())
1885            })?;
1886            for entry in entries.flatten() {
1887                let path = entry.path();
1888                if !is_dynamic_library(&path) {
1889                    continue;
1890                }
1891                let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
1892                    continue;
1893                };
1894                let normalized = normalize_name(stem.trim_start_matches("lib"));
1895                if normalized == wanted {
1896                    matches.push(path);
1897                }
1898            }
1899        }
1900        matches.sort_by_key(|path| path.components().count());
1901        let artifact = matches.into_iter().next().ok_or_else(|| {
1902            format!(
1903                "Cargo built `{}` but no cdylib artifact was found in {}",
1904                target.name,
1905                release_dir.display()
1906            )
1907        })?;
1908        artifacts.push(artifact);
1909    }
1910    Ok(artifacts)
1911}
1912
1913fn normalize_name(name: &str) -> String {
1914    name.replace('-', "_").to_ascii_lowercase()
1915}
1916
1917pub(crate) fn is_dynamic_library(path: &Path) -> bool {
1918    matches!(
1919        path.extension()
1920            .and_then(|extension| extension.to_str())
1921            .map(|extension| extension.to_ascii_lowercase())
1922            .as_deref(),
1923        Some("dll" | "so" | "dylib" | "pyd")
1924    )
1925}
1926
1927#[cfg(test)]
1928mod tests {
1929    use super::*;
1930
1931    fn args(values: &[&str]) -> Vec<String> {
1932        values.iter().map(|value| value.to_string()).collect()
1933    }
1934
1935    fn native_record(name: &str) -> InstalledNativePackage {
1936        InstalledNativePackage {
1937            name: name.to_string(),
1938            version: "1.2.3".to_string(),
1939            source: None,
1940            prebuilt: None,
1941            artifacts: vec![format!("{name}.dll")],
1942        }
1943    }
1944
1945    #[test]
1946    fn install_version_policy_allows_upgrades_and_requires_force_for_downgrades() {
1947        assert!(should_install_version("demo", None, "1.0.0", false).unwrap());
1948        for (current, requested) in [
1949            ("0.1.11", "0.1.12"),
1950            ("1.0.0-beta.1", "1.0.0"),
1951            ("1.0.0-beta.1", "1.0.0-beta.2"),
1952        ] {
1953            assert!(should_install_version("demo", Some(current), requested, false).unwrap());
1954            let error =
1955                should_install_version("demo", Some(requested), current, false).unwrap_err();
1956            assert!(error.contains("--force to downgrade"), "{error}");
1957            assert!(should_install_version("demo", Some(requested), current, true).unwrap());
1958        }
1959        for requested in ["1.2.3", "1.2.3+new-build"] {
1960            assert!(!should_install_version("demo", Some("1.2.3"), requested, false).unwrap());
1961            assert!(should_install_version("demo", Some("1.2.3"), requested, true).unwrap());
1962        }
1963        assert!(should_install_version("demo", Some("unknown"), "1.0.0", false).is_err());
1964        assert!(should_install_version("demo", Some("unknown"), "1.0.0", true).unwrap());
1965    }
1966
1967    #[test]
1968    fn native_install_same_version_is_unchanged_and_force_reinstalls_or_downgrades() {
1969        let temp = tempfile::tempdir().unwrap();
1970        let destination = temp.path().join("extensions");
1971        let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
1972        let build = temp.path().join("demo.dll");
1973        std::fs::create_dir_all(&destination).unwrap();
1974        std::fs::write(destination.join("demo.dll"), b"installed").unwrap();
1975        let record = native_record("demo");
1976        write_native_packages_at(&metadata, &[record.clone()]).unwrap();
1977        let original_registry = std::fs::read(&metadata).unwrap();
1978        std::fs::write(&build, b"rebuilt").unwrap();
1979
1980        assert!(install_native_package_at(
1981            &destination,
1982            &metadata,
1983            record.clone(),
1984            &[build.clone()],
1985            false,
1986        )
1987        .unwrap()
1988        .is_empty());
1989        assert_eq!(
1990            std::fs::read(destination.join("demo.dll")).unwrap(),
1991            b"installed"
1992        );
1993        assert_eq!(std::fs::read(&metadata).unwrap(), original_registry);
1994        let mut older = record.clone();
1995        older.version = "1.0.0".into();
1996        let error = install_native_package_at(
1997            &destination,
1998            &metadata,
1999            older.clone(),
2000            &[build.clone()],
2001            false,
2002        )
2003        .unwrap_err();
2004        assert!(error.contains("--force to downgrade"));
2005        assert_eq!(
2006            std::fs::read(destination.join("demo.dll")).unwrap(),
2007            b"installed"
2008        );
2009        assert_eq!(std::fs::read(&metadata).unwrap(), original_registry);
2010
2011        for forced in [record, older] {
2012            install_native_package_at(
2013                &destination,
2014                &metadata,
2015                forced.clone(),
2016                &[build.clone()],
2017                true,
2018            )
2019            .unwrap();
2020            let records = read_native_package_registry_at(&metadata)
2021                .unwrap()
2022                .into_records();
2023            assert_eq!(records[0].version, forced.version);
2024            let active = destination.join(&records[0].artifacts[0]);
2025            assert_eq!(std::fs::read(active).unwrap(), b"rebuilt");
2026            assert_eq!(
2027                std::fs::read(destination.join("demo.dll")).unwrap(),
2028                b"installed"
2029            );
2030        }
2031        assert_no_native_transaction_files(&destination);
2032    }
2033
2034    #[test]
2035    fn native_upgrade_does_not_overwrite_unowned_artifacts() {
2036        let temp = tempfile::tempdir().unwrap();
2037        let destination = temp.path().join("extensions");
2038        let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2039        std::fs::create_dir_all(&destination).unwrap();
2040        std::fs::write(destination.join("demo.dll"), b"installed").unwrap();
2041        std::fs::write(destination.join("unowned.dll"), b"unowned").unwrap();
2042        write_native_packages_at(&metadata, &[native_record("demo")]).unwrap();
2043        let original_registry = std::fs::read(&metadata).unwrap();
2044        let artifact = temp.path().join("build-unowned.dll");
2045        std::fs::write(&artifact, b"new").unwrap();
2046        let mut upgraded = native_record("demo");
2047        upgraded.version = "2.0.0".into();
2048        let installed =
2049            install_native_package_at(&destination, &metadata, upgraded, &[artifact], false)
2050                .unwrap();
2051        assert_eq!(installed.len(), 1);
2052        assert_eq!(
2053            std::fs::read(destination.join("demo.dll")).unwrap(),
2054            b"installed"
2055        );
2056        assert_eq!(
2057            std::fs::read(destination.join("unowned.dll")).unwrap(),
2058            b"unowned"
2059        );
2060        assert_ne!(std::fs::read(&metadata).unwrap(), original_registry);
2061        assert_no_native_transaction_files(&destination);
2062    }
2063
2064    struct TempAgent {
2065        _guard: std::sync::MutexGuard<'static, ()>,
2066        temp: tempfile::TempDir,
2067        previous: Option<std::ffi::OsString>,
2068    }
2069
2070    impl TempAgent {
2071        fn new() -> Self {
2072            let guard = crate::config::test_support::env_lock()
2073                .lock()
2074                .unwrap_or_else(|poisoned| poisoned.into_inner());
2075            let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
2076            let temp = tempfile::tempdir().unwrap();
2077            std::env::set_var(crate::config::CONFIG_DIR_ENV, temp.path());
2078            Self {
2079                _guard: guard,
2080                temp,
2081                previous,
2082            }
2083        }
2084
2085        fn metadata_path(&self) -> PathBuf {
2086            self.temp.path().join(NATIVE_PACKAGES_FILE)
2087        }
2088    }
2089
2090    impl Drop for TempAgent {
2091        fn drop(&mut self) {
2092            match self.previous.take() {
2093                Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2094                None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2095            }
2096        }
2097    }
2098
2099    #[test]
2100    fn parses_registry_package_and_options() {
2101        let parsed = parse_args(&args(&["my-extension", "--version", "1.2.3", "--force"])).unwrap();
2102        assert_eq!(parsed.package, "my-extension");
2103        assert_eq!(parsed.version.as_deref(), Some("1.2.3"));
2104        assert!(parsed.force);
2105    }
2106
2107    #[test]
2108    fn github_override_and_source_build_are_explicit_options() {
2109        let custom = parse_args(&args(&[
2110            "my-extension",
2111            "--github",
2112            "publisher/monorepo",
2113            "--version",
2114            "1.2.3",
2115        ]))
2116        .unwrap();
2117        assert_eq!(custom.repository.as_deref(), Some("publisher/monorepo"));
2118        assert_eq!(custom.version.as_deref(), Some("1.2.3"));
2119        assert!(!custom.build);
2120        let custom_url = parse_args(&args(&[
2121            "my-extension",
2122            "--github",
2123            "https://github.com/publisher/monorepo",
2124        ]))
2125        .unwrap();
2126        assert!(custom_url.repository.is_some());
2127        assert!(
2128            parse_args(&args(&["my-extension", "--build", "--locked"]))
2129                .unwrap()
2130                .build
2131        );
2132        assert!(parse_args(&args(&["my-extension", "--offline"])).is_ok());
2133        assert!(parse_args(&args(&[
2134            "my-extension",
2135            "--github",
2136            "owner/repo",
2137            "--build"
2138        ]))
2139        .is_err());
2140        assert!(parse_args(&args(&[
2141            "my-extension",
2142            "--github",
2143            "owner/repo",
2144            "--path",
2145            "."
2146        ]))
2147        .is_err());
2148        assert!(parse_args(&args(&["my-extension", "--locked"])).is_err());
2149    }
2150
2151    #[test]
2152    fn synchronous_source_backend_cannot_silently_compile_a_prebuilt_request() {
2153        let _agent = TempAgent::new();
2154        assert_eq!(run(&args(&["demo"])), 2);
2155        assert_eq!(
2156            run(&args(&[
2157                "demo",
2158                "--github",
2159                "publisher/repo",
2160                "--version",
2161                "1.2.3"
2162            ])),
2163            2
2164        );
2165    }
2166
2167    #[test]
2168    fn uninstall_prebuilt_package_uses_its_recorded_library_name() {
2169        let agent = TempAgent::new();
2170        let directory = agent.temp.path().join("extensions");
2171        std::fs::create_dir_all(&directory).unwrap();
2172        std::fs::write(directory.join("different_library.dll"), b"prebuilt library").unwrap();
2173        let record: InstalledNativePackage = serde_json::from_value(serde_json::json!({
2174            "name":"demo", "version":"1.2.3", "source":"prebuilt:https://github.com/owner/repo",
2175            "artifacts":["different_library.dll"],
2176            "prebuilt":{"repository":"https://github.com/owner/repo", "target":"x86_64-pc-windows-msvc",
2177                "manifest_sha256":"a".repeat(64),"source_commit":"b".repeat(40),"sdk_version":"0.3.23","plugin_abi":4,
2178                "archive_sha256":"c".repeat(64),"library_sha256":"d".repeat(64),"custom_repository":true}
2179        })).unwrap();
2180        write_native_packages_at(&agent.metadata_path(), &[record]).unwrap();
2181        assert_eq!(uninstall(&args(&["demo"])), 0);
2182        assert!(!directory.join("different_library.dll").exists());
2183        assert!(installed_native_packages_strict().unwrap().is_empty());
2184    }
2185
2186    #[test]
2187    fn parses_local_package() {
2188        let parsed = parse_args(&args(&["--path", "../extension", "my-extension"])).unwrap();
2189        assert_eq!(parsed.path, Some(PathBuf::from("../extension")));
2190    }
2191
2192    #[test]
2193    fn local_path_can_infer_package_name() {
2194        let parsed = parse_args(&args(&["--path", ".", "--force"])).unwrap();
2195        assert!(!parsed.package.is_empty());
2196        assert!(valid_package_name(&parsed.package));
2197        assert!(parsed.force);
2198    }
2199
2200    #[test]
2201    fn rejects_non_extension_options_and_invalid_names() {
2202        assert!(parse_args(&args(&["my.extension"])).is_err());
2203        assert!(parse_args(&args(&["my-extension", "--unknown"])).is_err());
2204        assert!(parse_args(&args(&["my-extension", "--path", ".", "--version", "1"])).is_err());
2205    }
2206
2207    #[test]
2208    fn cdylib_hint_recommends_unified_and_documents_legacy_compatibility() {
2209        assert!(CDYLIB_EXTENSION_HINT.contains("rpi_plugin_register"));
2210        assert!(CDYLIB_EXTENSION_HINT.contains("rpi_plugin_register_v2"));
2211        assert!(CDYLIB_EXTENSION_HINT.contains("rpi_plugin_register_v3"));
2212    }
2213
2214    #[test]
2215    fn registry_read_distinguishes_missing_and_valid_files() {
2216        let temp = tempfile::tempdir().unwrap();
2217        let path = temp.path().join(NATIVE_PACKAGES_FILE);
2218
2219        assert_eq!(
2220            read_native_package_registry_at(&path).unwrap(),
2221            NativePackageRegistry::Missing
2222        );
2223
2224        std::fs::write(
2225            &path,
2226            br#"[{"name":"demo","version":"1.2.3","source":null}]"#,
2227        )
2228        .unwrap();
2229        assert_eq!(
2230            read_native_package_registry_at(&path).unwrap(),
2231            NativePackageRegistry::Valid(vec![InstalledNativePackage {
2232                name: "demo".to_string(),
2233                version: "1.2.3".to_string(),
2234                source: None,
2235                prebuilt: None,
2236                artifacts: Vec::new(),
2237            }])
2238        );
2239    }
2240
2241    #[test]
2242    fn active_global_artifacts_exclude_retired_versioned_files() {
2243        let agent = tempfile::tempdir().unwrap();
2244        let extensions = agent.path().join("extensions");
2245        std::fs::create_dir_all(&extensions).unwrap();
2246        let active = "libdemo-v2.0.0-0123456789abcdef0123456789abcdef.dll";
2247        let retired = "libdemo-v1.0.0-abcdef0123456789abcdef0123456789.dll";
2248        std::fs::write(extensions.join(active), b"active").unwrap();
2249        std::fs::write(extensions.join(retired), b"retired").unwrap();
2250        std::fs::write(extensions.join("legacy.dll"), b"legacy").unwrap();
2251        write_native_packages_at(
2252            &agent.path().join(NATIVE_PACKAGES_FILE),
2253            &[InstalledNativePackage {
2254                name: "demo".into(),
2255                version: "2.0.0".into(),
2256                source: None,
2257                prebuilt: None,
2258                artifacts: vec![active.into()],
2259            }],
2260        )
2261        .unwrap();
2262
2263        assert_eq!(
2264            active_global_native_package_artifacts_at(agent.path()).unwrap(),
2265            vec![extensions.join(active)]
2266        );
2267        assert_eq!(
2268            unregistered_global_native_package_artifacts_at(agent.path()).unwrap(),
2269            vec![extensions.join("legacy.dll")]
2270        );
2271    }
2272
2273    #[test]
2274    fn registry_read_rejects_corrupt_json() {
2275        let temp = tempfile::tempdir().unwrap();
2276        let path = temp.path().join(NATIVE_PACKAGES_FILE);
2277        std::fs::write(&path, b"[{").unwrap();
2278
2279        let error = read_native_package_registry_at(&path).unwrap_err();
2280        assert!(error.contains("invalid JSON or schema"));
2281    }
2282
2283    #[test]
2284    fn registry_read_rejects_wrong_schema() {
2285        let temp = tempfile::tempdir().unwrap();
2286        let path = temp.path().join(NATIVE_PACKAGES_FILE);
2287        std::fs::write(&path, br#"{"name":"demo","version":"1.2.3"}"#).unwrap();
2288
2289        let error = read_native_package_registry_at(&path).unwrap_err();
2290        assert!(error.contains("invalid JSON or schema"));
2291    }
2292
2293    #[test]
2294    fn record_does_not_replace_a_corrupt_registry() {
2295        let temp = tempfile::tempdir().unwrap();
2296        let path = temp.path().join(NATIVE_PACKAGES_FILE);
2297        let original = b"[{broken metadata";
2298        std::fs::write(&path, original).unwrap();
2299
2300        assert!(record_native_package_at(&path, &native_record("demo")).is_err());
2301        assert_eq!(std::fs::read(&path).unwrap(), original);
2302    }
2303
2304    #[test]
2305    fn native_install_transaction_preserves_loaded_artifacts_and_publishes_new_ones() {
2306        let temp = tempfile::tempdir().unwrap();
2307        let destination = temp.path().join("extensions");
2308        let metadata_path = temp.path().join(NATIVE_PACKAGES_FILE);
2309        let build = temp.path().join("build");
2310        std::fs::create_dir_all(&destination).unwrap();
2311        std::fs::create_dir_all(&build).unwrap();
2312        std::fs::write(destination.join("old.dll"), b"old-only").unwrap();
2313        std::fs::write(destination.join("same.dll"), b"old-shared").unwrap();
2314        let old = InstalledNativePackage {
2315            name: "demo".into(),
2316            version: "1.0.0".into(),
2317            source: None,
2318            prebuilt: None,
2319            artifacts: vec!["old.dll".into(), "same.dll".into()],
2320        };
2321        write_native_packages_at(&metadata_path, &[old]).unwrap();
2322        std::fs::write(build.join("new.dll"), b"new-only").unwrap();
2323        std::fs::write(build.join("same.dll"), b"new-shared").unwrap();
2324
2325        let installed = install_native_package_at(
2326            &destination,
2327            &metadata_path,
2328            InstalledNativePackage {
2329                name: "demo".into(),
2330                version: "2.0.0".into(),
2331                source: None,
2332                prebuilt: None,
2333                artifacts: Vec::new(),
2334            },
2335            &[build.join("new.dll"), build.join("same.dll")],
2336            false,
2337        )
2338        .unwrap();
2339
2340        assert_eq!(installed.len(), 2);
2341        assert!(installed.iter().all(|path| path.exists()));
2342        assert_eq!(
2343            std::fs::read(destination.join("old.dll")).unwrap(),
2344            b"old-only"
2345        );
2346        assert_eq!(
2347            std::fs::read(destination.join("same.dll")).unwrap(),
2348            b"old-shared"
2349        );
2350        assert!(installed
2351            .iter()
2352            .any(|path| { std::fs::read(path).ok().as_deref() == Some(b"new-only".as_slice()) }));
2353        assert!(installed
2354            .iter()
2355            .any(|path| { std::fs::read(path).ok().as_deref() == Some(b"new-shared".as_slice()) }));
2356        let records = read_native_package_registry_at(&metadata_path)
2357            .unwrap()
2358            .into_records();
2359        assert_eq!(records.len(), 1);
2360        assert_eq!(records[0].version, "2.0.0");
2361        let active_names = installed
2362            .iter()
2363            .map(|path| path.file_name().unwrap().to_string_lossy().into_owned())
2364            .collect::<Vec<_>>();
2365        assert_eq!(records[0].artifacts, active_names);
2366        assert_no_native_transaction_files(temp.path());
2367        assert_no_native_transaction_files(&destination);
2368    }
2369
2370    #[test]
2371    fn native_install_transaction_rolls_back_artifacts_and_registry() {
2372        let temp = tempfile::tempdir().unwrap();
2373        let destination = temp.path().join("extensions");
2374        let metadata_path = temp.path().join(NATIVE_PACKAGES_FILE);
2375        let build = temp.path().join("build");
2376        std::fs::create_dir_all(&destination).unwrap();
2377        std::fs::create_dir_all(&build).unwrap();
2378        std::fs::write(destination.join("old.dll"), b"old-only").unwrap();
2379        std::fs::write(destination.join("same.dll"), b"old-shared").unwrap();
2380        let old = InstalledNativePackage {
2381            name: "demo".into(),
2382            version: "1.0.0".into(),
2383            source: None,
2384            prebuilt: None,
2385            artifacts: vec!["old.dll".into(), "same.dll".into()],
2386        };
2387        write_native_packages_at(&metadata_path, &[old]).unwrap();
2388        let original_registry = std::fs::read(&metadata_path).unwrap();
2389        std::fs::write(build.join("new.dll"), b"new-only").unwrap();
2390        std::fs::write(build.join("same.dll"), b"new-shared").unwrap();
2391
2392        let error = install_native_package_at_with_hook(
2393            &destination,
2394            &metadata_path,
2395            InstalledNativePackage {
2396                name: "demo".into(),
2397                version: "2.0.0".into(),
2398                source: None,
2399                prebuilt: None,
2400                artifacts: Vec::new(),
2401            },
2402            &[build.join("new.dll"), build.join("same.dll")],
2403            &[],
2404            false,
2405            |_, target| {
2406                if target == metadata_path {
2407                    Err("injected registry activation failure".into())
2408                } else {
2409                    Ok(())
2410                }
2411            },
2412        )
2413        .unwrap_err();
2414
2415        assert!(error.contains("injected registry activation failure"));
2416        assert_eq!(std::fs::read(&metadata_path).unwrap(), original_registry);
2417        assert_eq!(
2418            std::fs::read(destination.join("old.dll")).unwrap(),
2419            b"old-only"
2420        );
2421        assert_eq!(
2422            std::fs::read(destination.join("same.dll")).unwrap(),
2423            b"old-shared"
2424        );
2425        assert!(!destination.join("new.dll").exists());
2426        assert_no_native_transaction_files(temp.path());
2427        assert_no_native_transaction_files(&destination);
2428    }
2429
2430    fn assert_no_native_transaction_files(directory: &Path) {
2431        for entry in std::fs::read_dir(directory).unwrap() {
2432            let name = entry.unwrap().file_name().to_string_lossy().into_owned();
2433            assert!(!name.contains(".rpi-stage-"), "left staged file: {name}");
2434            assert!(!name.contains(".rpi-backup-"), "left backup file: {name}");
2435            assert!(
2436                !name.contains(".rpi-assets-stage-"),
2437                "left asset stage: {name}"
2438            );
2439        }
2440    }
2441
2442    #[test]
2443    fn best_effort_registry_read_omits_corrupt_metadata() {
2444        let agent = TempAgent::new();
2445        std::fs::write(agent.metadata_path(), b"[{broken metadata").unwrap();
2446
2447        assert!(installed_native_packages().is_empty());
2448    }
2449
2450    #[test]
2451    fn install_fails_closed_before_work_when_registry_is_corrupt() {
2452        let agent = TempAgent::new();
2453        let path = agent.metadata_path();
2454        let original = b"[{broken metadata";
2455        std::fs::write(&path, original).unwrap();
2456
2457        assert_eq!(run(&args(&["demo"])), 1);
2458        assert_eq!(std::fs::read(&path).unwrap(), original);
2459        assert!(!agent.temp.path().join("extensions").exists());
2460    }
2461
2462    #[test]
2463    fn uninstall_fails_closed_without_removing_artifacts() {
2464        let agent = TempAgent::new();
2465        let path = agent.metadata_path();
2466        let original = b"[{broken metadata";
2467        std::fs::write(&path, original).unwrap();
2468        let extension_dir = agent.temp.path().join("extensions");
2469        std::fs::create_dir_all(&extension_dir).unwrap();
2470        let artifact = extension_dir.join("demo.dll");
2471        std::fs::write(&artifact, b"extension").unwrap();
2472
2473        assert_eq!(uninstall(&args(&["demo"])), 1);
2474        assert_eq!(std::fs::read(&path).unwrap(), original);
2475        assert_eq!(std::fs::read(artifact).unwrap(), b"extension");
2476    }
2477
2478    #[test]
2479    fn uninstall_commits_artifact_and_registry_changes_together() {
2480        let agent = TempAgent::new();
2481        let extension_dir = agent.temp.path().join("extensions");
2482        std::fs::create_dir_all(&extension_dir).unwrap();
2483        std::fs::write(extension_dir.join("demo.dll"), b"demo").unwrap();
2484        std::fs::write(extension_dir.join("other.dll"), b"other").unwrap();
2485        write_native_packages_at(
2486            &agent.metadata_path(),
2487            &[native_record("demo"), native_record("other")],
2488        )
2489        .unwrap();
2490
2491        assert_eq!(uninstall(&args(&["demo"])), 0);
2492        assert!(!extension_dir.join("demo.dll").exists());
2493        assert_eq!(
2494            std::fs::read(extension_dir.join("other.dll")).unwrap(),
2495            b"other"
2496        );
2497        assert_eq!(
2498            read_native_package_registry_at(&agent.metadata_path())
2499                .unwrap()
2500                .into_records(),
2501            vec![native_record("other")]
2502        );
2503        assert_no_native_transaction_files(agent.temp.path());
2504        assert_no_native_transaction_files(&extension_dir);
2505    }
2506
2507    #[test]
2508    fn uninstall_uses_recorded_artifacts_without_name_fallback() {
2509        let agent = TempAgent::new();
2510        let extension_dir = agent.temp.path().join("extensions");
2511        std::fs::create_dir_all(&extension_dir).unwrap();
2512        std::fs::write(extension_dir.join("recorded.dll"), b"managed").unwrap();
2513        std::fs::write(extension_dir.join("demo.dll"), b"unmanaged").unwrap();
2514        write_native_packages_at(
2515            &agent.metadata_path(),
2516            &[InstalledNativePackage {
2517                name: "demo".into(),
2518                version: "1.2.3".into(),
2519                source: None,
2520                prebuilt: None,
2521                artifacts: vec!["recorded.dll".into()],
2522            }],
2523        )
2524        .unwrap();
2525
2526        assert_eq!(uninstall(&args(&["demo"])), 0);
2527        assert!(!extension_dir.join("recorded.dll").exists());
2528        assert_eq!(
2529            std::fs::read(extension_dir.join("demo.dll")).unwrap(),
2530            b"unmanaged"
2531        );
2532        assert!(!agent.metadata_path().exists());
2533        assert_no_native_transaction_files(agent.temp.path());
2534        assert_no_native_transaction_files(&extension_dir);
2535    }
2536
2537    /// A prebuilt record whose ledger names `assets`, as the archive installer writes it.
2538    fn prebuilt_record(
2539        name: &str,
2540        version: &str,
2541        assets: &[(&str, &[u8])],
2542    ) -> InstalledNativePackage {
2543        InstalledNativePackage {
2544            name: name.into(),
2545            version: version.into(),
2546            source: Some(format!("prebuilt:https://github.com/owner/{name}")),
2547            prebuilt: Some(crate::prebuilt_extensions::PrebuiltRecord {
2548                repository: format!("https://github.com/owner/{name}"),
2549                target: "x86_64-unknown-linux-gnu".into(),
2550                manifest_sha256: "a".repeat(64),
2551                source_commit: "b".repeat(40),
2552                sdk_version: "0.3.23".into(),
2553                plugin_abi: 4,
2554                archive_sha256: "c".repeat(64),
2555                library_sha256: "d".repeat(64),
2556                custom_repository: false,
2557                assets: assets
2558                    .iter()
2559                    .map(|(path, bytes)| crate::prebuilt_extensions::PrebuiltAsset {
2560                        path: (*path).into(),
2561                        sha256: crate::prebuilt_extensions::hash(bytes),
2562                    })
2563                    .collect(),
2564            }),
2565            artifacts: Vec::new(),
2566        }
2567    }
2568
2569    fn sidecars(assets: &[(&str, &[u8])]) -> Vec<NativeAssetFile> {
2570        assets
2571            .iter()
2572            .map(|(path, bytes)| NativeAssetFile {
2573                path: (*path).into(),
2574                bytes: bytes.to_vec(),
2575            })
2576            .collect()
2577    }
2578
2579    #[test]
2580    fn native_asset_upgrade_replaces_sidecars_and_prunes_stale_ones() {
2581        let temp = tempfile::tempdir().unwrap();
2582        let destination = temp.path().join("extensions");
2583        let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2584        let library = temp.path().join("libdemo.so");
2585        std::fs::write(&library, b"library v1").unwrap();
2586        let v1 = [
2587            ("runtime/main.mjs", &b"v1"[..]),
2588            ("runtime/old.mjs", &b"old"[..]),
2589        ];
2590        install_native_package_with_assets_at(
2591            &destination,
2592            &metadata,
2593            prebuilt_record("demo", "1.0.0", &v1),
2594            std::slice::from_ref(&library),
2595            &sidecars(&v1),
2596            false,
2597        )
2598        .unwrap();
2599        assert_eq!(
2600            std::fs::read(destination.join("runtime/old.mjs")).unwrap(),
2601            b"old"
2602        );
2603
2604        std::fs::write(&library, b"library v2").unwrap();
2605        let v2 = [
2606            ("runtime/main.mjs", &b"v2"[..]),
2607            ("runtime/deep/lib.mjs", &b"deep"[..]),
2608        ];
2609        let installed = install_native_package_with_assets_at(
2610            &destination,
2611            &metadata,
2612            prebuilt_record("demo", "2.0.0", &v2),
2613            std::slice::from_ref(&library),
2614            &sidecars(&v2),
2615            false,
2616        )
2617        .unwrap();
2618        assert_eq!(installed.len(), 3);
2619        let active_library = installed
2620            .iter()
2621            .find(|path| is_dynamic_library(path))
2622            .expect("installed library");
2623        assert_ne!(active_library, &destination.join("libdemo.so"));
2624        assert_eq!(std::fs::read(active_library).unwrap(), b"library v2");
2625        assert_eq!(
2626            std::fs::read(destination.join("libdemo.so")).unwrap(),
2627            b"library v1"
2628        );
2629        assert_eq!(
2630            std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2631            b"v2"
2632        );
2633        assert_eq!(
2634            std::fs::read(destination.join("runtime/deep/lib.mjs")).unwrap(),
2635            b"deep"
2636        );
2637        assert!(!destination.join("runtime/old.mjs").exists());
2638        let records = read_native_package_registry_at(&metadata)
2639            .unwrap()
2640            .into_records();
2641        assert_eq!(records[0].version, "2.0.0");
2642        let ledger: Vec<&str> = recorded_asset_paths(&records[0]);
2643        assert_eq!(ledger, vec!["runtime/main.mjs", "runtime/deep/lib.mjs"]);
2644        // Dropping every sidecar removes the files and the directories they emptied.
2645        install_native_package_with_assets_at(
2646            &destination,
2647            &metadata,
2648            prebuilt_record("demo", "3.0.0", &[]),
2649            std::slice::from_ref(&library),
2650            &[],
2651            false,
2652        )
2653        .unwrap();
2654        assert!(!destination.join("runtime").exists());
2655        let records = read_native_package_registry_at(&metadata)
2656            .unwrap()
2657            .into_records();
2658        let active_library = destination.join(&records[0].artifacts[0]);
2659        assert_eq!(std::fs::read(active_library).unwrap(), b"library v2");
2660        assert_eq!(
2661            std::fs::read(destination.join("libdemo.so")).unwrap(),
2662            b"library v1"
2663        );
2664        assert_no_native_transaction_files(temp.path());
2665        assert_no_native_transaction_files(&destination);
2666    }
2667
2668    #[test]
2669    fn native_asset_failure_during_activation_restores_the_previous_install() {
2670        let temp = tempfile::tempdir().unwrap();
2671        let destination = temp.path().join("extensions");
2672        let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2673        let library = temp.path().join("libdemo.so");
2674        std::fs::write(&library, b"old library").unwrap();
2675        let v1 = [("runtime/main.mjs", &b"v1"[..])];
2676        install_native_package_with_assets_at(
2677            &destination,
2678            &metadata,
2679            prebuilt_record("demo", "1.0.0", &v1),
2680            std::slice::from_ref(&library),
2681            &sidecars(&v1),
2682            false,
2683        )
2684        .unwrap();
2685        let original_registry = std::fs::read(&metadata).unwrap();
2686
2687        std::fs::write(&library, b"new library").unwrap();
2688        let v2 = [
2689            ("runtime/main.mjs", &b"v2"[..]),
2690            ("runtime/extra.mjs", &b"extra"[..]),
2691        ];
2692        // Replacements activate in order: library, main.mjs, extra.mjs, registry.
2693        let error = install_native_package_at_with_hook(
2694            &destination,
2695            &metadata,
2696            prebuilt_record("demo", "2.0.0", &v2),
2697            std::slice::from_ref(&library),
2698            &sidecars(&v2),
2699            false,
2700            |index, _| {
2701                if index == 2 {
2702                    Err("injected sidecar activation failure".into())
2703                } else {
2704                    Ok(())
2705                }
2706            },
2707        )
2708        .unwrap_err();
2709
2710        assert!(
2711            error.contains("injected sidecar activation failure"),
2712            "{error}"
2713        );
2714        assert_eq!(
2715            std::fs::read(destination.join("libdemo.so")).unwrap(),
2716            b"old library"
2717        );
2718        assert_eq!(
2719            std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2720            b"v1"
2721        );
2722        assert!(!destination.join("runtime/extra.mjs").exists());
2723        assert_eq!(std::fs::read(&metadata).unwrap(), original_registry);
2724        assert_no_native_transaction_files(temp.path());
2725        assert_no_native_transaction_files(&destination);
2726        assert_no_native_transaction_files(&destination.join("runtime"));
2727
2728        // The same upgrade succeeds once the failure is gone.
2729        install_native_package_with_assets_at(
2730            &destination,
2731            &metadata,
2732            prebuilt_record("demo", "2.0.0", &v2),
2733            std::slice::from_ref(&library),
2734            &sidecars(&v2),
2735            false,
2736        )
2737        .unwrap();
2738        assert_eq!(
2739            std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2740            b"v2"
2741        );
2742        assert_eq!(
2743            std::fs::read(destination.join("runtime/extra.mjs")).unwrap(),
2744            b"extra"
2745        );
2746    }
2747
2748    #[test]
2749    fn native_asset_conflicts_and_overlaps_fail_before_any_write() {
2750        let temp = tempfile::tempdir().unwrap();
2751        let destination = temp.path().join("extensions");
2752        let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2753        let library = temp.path().join("libdemo.so");
2754        std::fs::write(&library, b"demo library").unwrap();
2755        let other = temp.path().join("libother.so");
2756        std::fs::write(&other, b"other library").unwrap();
2757        let shared = [("runtime/shared.mjs", &b"other"[..])];
2758        install_native_package_with_assets_at(
2759            &destination,
2760            &metadata,
2761            prebuilt_record("other", "1.0.0", &shared),
2762            std::slice::from_ref(&other),
2763            &sidecars(&shared),
2764            false,
2765        )
2766        .unwrap();
2767        let original_registry = std::fs::read(&metadata).unwrap();
2768
2769        let attempts: [&[(&str, &[u8])]; 3] = [
2770            &[("runtime/shared.mjs", &b"demo"[..])],
2771            &[("Runtime/Shared.mjs", &b"demo"[..])],
2772            &[("runtime", &b"file"[..]), ("runtime/own.mjs", &b"own"[..])],
2773        ];
2774        for assets in attempts {
2775            let error = install_native_package_with_assets_at(
2776                &destination,
2777                &metadata,
2778                prebuilt_record("demo", "1.0.0", assets),
2779                std::slice::from_ref(&library),
2780                &sidecars(assets),
2781                false,
2782            )
2783            .unwrap_err();
2784            assert!(
2785                error.contains("conflicts with asset")
2786                    || error.contains("duplicate or overlapping"),
2787                "{error}"
2788            );
2789            assert!(!destination.join("libdemo.so").exists());
2790            assert_eq!(
2791                std::fs::read(destination.join("runtime/shared.mjs")).unwrap(),
2792                b"other"
2793            );
2794            assert_eq!(std::fs::read(&metadata).unwrap(), original_registry);
2795            assert_eq!(
2796                std::fs::read_dir(destination.join("runtime"))
2797                    .unwrap()
2798                    .count(),
2799                1
2800            );
2801            assert_no_native_transaction_files(&destination);
2802        }
2803    }
2804
2805    #[test]
2806    fn native_asset_replacing_an_unowned_file_requires_force() {
2807        let temp = tempfile::tempdir().unwrap();
2808        let destination = temp.path().join("extensions");
2809        let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2810        let library = temp.path().join("libdemo.so");
2811        std::fs::write(&library, b"demo library").unwrap();
2812        std::fs::create_dir_all(destination.join("runtime")).unwrap();
2813        std::fs::write(destination.join("runtime/main.mjs"), b"user file").unwrap();
2814        let assets = [("runtime/main.mjs", &b"demo"[..])];
2815
2816        let error = install_native_package_with_assets_at(
2817            &destination,
2818            &metadata,
2819            prebuilt_record("demo", "1.0.0", &assets),
2820            std::slice::from_ref(&library),
2821            &sidecars(&assets),
2822            false,
2823        )
2824        .unwrap_err();
2825        assert!(error.contains("use --force to replace it"), "{error}");
2826        assert_eq!(
2827            std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2828            b"user file"
2829        );
2830        assert!(!metadata.exists());
2831
2832        install_native_package_with_assets_at(
2833            &destination,
2834            &metadata,
2835            prebuilt_record("demo", "1.0.0", &assets),
2836            std::slice::from_ref(&library),
2837            &sidecars(&assets),
2838            true,
2839        )
2840        .unwrap();
2841        assert_eq!(
2842            std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2843            b"demo"
2844        );
2845    }
2846
2847    #[test]
2848    fn uninstall_removes_recorded_sidecars_and_only_the_directories_they_empty() {
2849        let agent = TempAgent::new();
2850        let extension_dir = agent.temp.path().join("extensions");
2851        std::fs::create_dir_all(extension_dir.join("runtime/deep")).unwrap();
2852        std::fs::write(extension_dir.join("libdemo.so"), b"library").unwrap();
2853        std::fs::write(extension_dir.join("runtime/main.mjs"), b"main").unwrap();
2854        std::fs::write(extension_dir.join("runtime/deep/lib.mjs"), b"deep").unwrap();
2855        std::fs::write(extension_dir.join("runtime/user.mjs"), b"user").unwrap();
2856        let mut record = prebuilt_record(
2857            "demo",
2858            "1.0.0",
2859            &[
2860                ("runtime/main.mjs", &b"main"[..]),
2861                ("runtime/deep/lib.mjs", &b"deep"[..]),
2862            ],
2863        );
2864        record.artifacts = vec!["libdemo.so".into()];
2865        write_native_packages_at(&agent.metadata_path(), &[record]).unwrap();
2866
2867        assert_eq!(uninstall(&args(&["demo"])), 0);
2868        assert!(!extension_dir.join("libdemo.so").exists());
2869        assert!(!extension_dir.join("runtime/main.mjs").exists());
2870        assert!(!extension_dir.join("runtime/deep").exists());
2871        assert_eq!(
2872            std::fs::read(extension_dir.join("runtime/user.mjs")).unwrap(),
2873            b"user"
2874        );
2875        assert!(!agent.metadata_path().exists());
2876        assert_no_native_transaction_files(agent.temp.path());
2877        assert_no_native_transaction_files(&extension_dir);
2878    }
2879
2880    #[test]
2881    fn native_asset_case_only_rename_prunes_the_old_sidecar() {
2882        let temp = tempfile::tempdir().unwrap();
2883        let destination = temp.path().join("extensions");
2884        let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2885        let library = temp.path().join("libdemo.so");
2886        std::fs::write(&library, b"library v1").unwrap();
2887        let first = [("Runtime/main.mjs", &b"worker v1"[..])];
2888        install_native_package_with_assets_at(
2889            &destination,
2890            &metadata,
2891            prebuilt_record("demo", "1.0.0", &first),
2892            std::slice::from_ref(&library),
2893            &sidecars(&first),
2894            false,
2895        )
2896        .unwrap();
2897        let second = [("runtime/main.mjs", &b"worker v2"[..])];
2898        install_native_package_with_assets_at(
2899            &destination,
2900            &metadata,
2901            prebuilt_record("demo", "1.1.0", &second),
2902            std::slice::from_ref(&library),
2903            &sidecars(&second),
2904            false,
2905        )
2906        .unwrap();
2907
2908        assert_eq!(
2909            std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2910            b"worker v2"
2911        );
2912        // The old name is no longer recorded, so it must not survive beside the new one.
2913        fn store_files(dir: &Path) -> usize {
2914            std::fs::read_dir(dir)
2915                .unwrap()
2916                .map(|entry| {
2917                    let path = entry.unwrap().path();
2918                    if path.is_dir() {
2919                        store_files(&path)
2920                    } else {
2921                        1
2922                    }
2923                })
2924                .sum()
2925        }
2926        assert_eq!(store_files(&destination), 3);
2927    }
2928
2929    #[test]
2930    fn native_asset_case_variant_of_an_owned_sidecar_is_not_owned() {
2931        let temp = tempfile::tempdir().unwrap();
2932        let destination = temp.path().join("extensions");
2933        let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2934        let library = temp.path().join("libdemo.so");
2935        std::fs::write(&library, b"library").unwrap();
2936        let first = [("Runtime/main.mjs", &b"worker v1"[..])];
2937        install_native_package_with_assets_at(
2938            &destination,
2939            &metadata,
2940            prebuilt_record("demo", "1.0.0", &first),
2941            std::slice::from_ref(&library),
2942            &sidecars(&first),
2943            false,
2944        )
2945        .unwrap();
2946        std::fs::create_dir_all(destination.join("runtime")).unwrap();
2947        std::fs::write(destination.join("runtime/main.mjs"), b"user file").unwrap();
2948        if std::fs::read(destination.join("Runtime/main.mjs")).unwrap() == b"user file" {
2949            return; // case-insensitive store: the two names are one file, nothing to test
2950        }
2951
2952        let second = [("runtime/main.mjs", &b"worker v2"[..])];
2953        let error = install_native_package_with_assets_at(
2954            &destination,
2955            &metadata,
2956            prebuilt_record("demo", "1.1.0", &second),
2957            std::slice::from_ref(&library),
2958            &sidecars(&second),
2959            false,
2960        )
2961        .unwrap_err();
2962        assert!(error.contains("--force"), "{error}");
2963        assert_eq!(
2964            std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2965            b"user file"
2966        );
2967        assert_eq!(
2968            std::fs::read(destination.join("Runtime/main.mjs")).unwrap(),
2969            b"worker v1"
2970        );
2971
2972        install_native_package_with_assets_at(
2973            &destination,
2974            &metadata,
2975            prebuilt_record("demo", "1.1.0", &second),
2976            std::slice::from_ref(&library),
2977            &sidecars(&second),
2978            true,
2979        )
2980        .unwrap();
2981        assert_eq!(
2982            std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2983            b"worker v2"
2984        );
2985        assert!(!destination.join("Runtime").exists());
2986    }
2987
2988    #[test]
2989    fn native_asset_failed_first_install_removes_the_directories_it_created() {
2990        let temp = tempfile::tempdir().unwrap();
2991        let destination = temp.path().join("extensions");
2992        let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2993        let library = temp.path().join("libdemo.so");
2994        std::fs::write(&library, b"library").unwrap();
2995        std::fs::create_dir_all(destination.join("keep")).unwrap();
2996        let assets = [("runtime/deep/x.mjs", &b"x"[..]), ("keep/y.mjs", &b"y"[..])];
2997        // Replacements activate in order: library, x.mjs, y.mjs, registry.
2998        let error = install_native_package_at_with_hook(
2999            &destination,
3000            &metadata,
3001            prebuilt_record("demo", "1.0.0", &assets),
3002            std::slice::from_ref(&library),
3003            &sidecars(&assets),
3004            false,
3005            |index, _| {
3006                if index == 2 {
3007                    Err("injected sidecar activation failure".into())
3008                } else {
3009                    Ok(())
3010                }
3011            },
3012        )
3013        .unwrap_err();
3014
3015        assert!(error.contains("injected"), "{error}");
3016        assert!(!destination.join("runtime").exists());
3017        // A directory that existed before the install is left alone, even though it is empty.
3018        assert!(destination.join("keep").is_dir());
3019        assert!(!destination.join("libdemo.so").exists());
3020        assert!(!metadata.exists());
3021        assert_no_native_transaction_files(temp.path());
3022        assert_no_native_transaction_files(&destination);
3023    }
3024
3025    #[test]
3026    fn native_asset_overlap_within_one_package_is_rejected_before_any_write() {
3027        // No other package is installed, so only the intra-package check can reject this.
3028        let temp = tempfile::tempdir().unwrap();
3029        let destination = temp.path().join("extensions");
3030        let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
3031        let library = temp.path().join("libdemo.so");
3032        std::fs::write(&library, b"demo library").unwrap();
3033        let assets = [("runtime", &b"file"[..]), ("runtime/own.mjs", &b"own"[..])];
3034
3035        let error = install_native_package_with_assets_at(
3036            &destination,
3037            &metadata,
3038            prebuilt_record("demo", "1.0.0", &assets),
3039            std::slice::from_ref(&library),
3040            &sidecars(&assets),
3041            false,
3042        )
3043        .unwrap_err();
3044        assert!(error.contains("duplicate or overlapping"), "{error}");
3045        assert_eq!(std::fs::read_dir(&destination).unwrap().count(), 0);
3046        assert!(!metadata.exists());
3047    }
3048
3049    #[cfg(unix)]
3050    #[test]
3051    fn native_asset_parent_links_are_refused_without_writing_through_them() {
3052        let temp = tempfile::tempdir().unwrap();
3053        let destination = temp.path().join("extensions");
3054        let outside = temp.path().join("outside");
3055        let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
3056        let library = temp.path().join("libdemo.so");
3057        std::fs::write(&library, b"demo library").unwrap();
3058        std::fs::create_dir_all(&destination).unwrap();
3059        std::fs::create_dir_all(&outside).unwrap();
3060        std::os::unix::fs::symlink(&outside, destination.join("runtime")).unwrap();
3061        let assets = [("runtime/main.mjs", &b"demo"[..])];
3062
3063        let error = install_native_package_with_assets_at(
3064            &destination,
3065            &metadata,
3066            prebuilt_record("demo", "1.0.0", &assets),
3067            std::slice::from_ref(&library),
3068            &sidecars(&assets),
3069            false,
3070        )
3071        .unwrap_err();
3072        assert!(error.contains("is not a plain directory"), "{error}");
3073        assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0);
3074        assert!(!destination.join("libdemo.so").exists());
3075    }
3076}