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