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
18const INSTALLER_MANIFEST: &str = "rpi-extension-installer";
19const NATIVE_PACKAGES_FILE: &str = "native-packages.json";
20const NATIVE_PACKAGES_LOCK_FILE: &str = "native-packages.lock";
21const 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)";
22
23/// A Rust-native extension installed through `rpi install`.
24///
25/// `source` is absent for Cargo registry builds, contains the local source
26/// path for `--path`, or marks prebuilt packages so older hosts do not rebuild
27/// them. `prebuilt` stores distribution provenance. Local development crates
28/// are intentionally not eligible for automatic registry updates.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct InstalledNativePackage {
31    pub name: String,
32    pub version: String,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub source: Option<String>,
35    /// Exact prebuilt distribution provenance. Missing in older Cargo/local records.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub prebuilt: Option<crate::prebuilt_extensions::PrebuiltRecord>,
38    /// Dynamic-library file names copied into the extension store. Older
39    /// metadata files omit this field; uninstall falls back to crate-name
40    /// matching for those records.
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub artifacts: Vec<String>,
43}
44
45impl InstalledNativePackage {
46    /// Prebuilt provenance also uses `source`; only local development builds
47    /// should be excluded from registry update discovery and commands.
48    pub(crate) fn supports_registry_update(&self) -> bool {
49        self.source.is_none() || self.prebuilt.is_some()
50    }
51}
52
53/// Decide before downloads/builds, then recheck under the package mutation lock.
54/// `false` means the requested version is already installed; only --force
55/// reinstalls it. Build metadata does not change semantic-version precedence.
56pub(crate) fn should_install_version(
57    name: &str,
58    current: Option<&str>,
59    requested: &str,
60    force: bool,
61) -> Result<bool, String> {
62    let Some(current) = current.filter(|_| !force) else {
63        return Ok(true);
64    };
65    let parse = |version: &str| {
66        semver::Version::parse(version).map_err(|_| {
67            format!("cannot compare version `{version}` for `{name}`; use --force to replace it")
68        })
69    };
70    match parse(requested)?.cmp_precedence(&parse(current)?) {
71        std::cmp::Ordering::Greater => Ok(true),
72        std::cmp::Ordering::Equal => Ok(false),
73        std::cmp::Ordering::Less => Err(format!(
74            "{name} {current} is installed; use --force to downgrade to {requested}"
75        )),
76    }
77}
78
79pub(crate) fn print_already_installed(name: &str, version: &str) {
80    println!("{name} {version} is already installed; use --force to reinstall it");
81}
82
83/// The on-disk state of the Rust-native extension registry.
84///
85/// A malformed or unreadable registry is returned as an error, rather than
86/// being conflated with a missing file. Mutation paths use this distinction to
87/// avoid replacing damaged metadata with a newly generated registry.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum NativePackageRegistry {
90    /// No registry has been created yet.
91    Missing,
92    /// A registry was present and passed JSON/schema validation.
93    Valid(Vec<InstalledNativePackage>),
94}
95
96impl NativePackageRegistry {
97    fn into_records(self) -> Vec<InstalledNativePackage> {
98        match self {
99            Self::Missing => Vec::new(),
100            Self::Valid(records) => records,
101        }
102    }
103}
104
105struct NativePackageMutationLock {
106    file: Option<std::fs::File>,
107}
108
109impl NativePackageMutationLock {
110    fn acquire(metadata_path: &Path) -> Result<Self, String> {
111        let parent = metadata_path
112            .parent()
113            .ok_or_else(|| "native package metadata has no parent".to_string())?;
114        std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
115        let lock_path = parent.join(NATIVE_PACKAGES_LOCK_FILE);
116        let file = std::fs::OpenOptions::new()
117            .create(true)
118            .read(true)
119            .write(true)
120            .open(&lock_path)
121            .map_err(|error| {
122                format!(
123                    "could not open native package lock {}: {error}",
124                    lock_path.display()
125                )
126            })?;
127        fs2::FileExt::try_lock_exclusive(&file).map_err(|error| {
128            format!("could not lock native package state (another install may be running): {error}")
129        })?;
130        Ok(Self { file: Some(file) })
131    }
132}
133
134impl Drop for NativePackageMutationLock {
135    fn drop(&mut self) {
136        if let Some(file) = self.file.take() {
137            let _ = fs2::FileExt::unlock(&file);
138        }
139    }
140}
141
142#[derive(Debug, Clone)]
143struct InstallOptions {
144    package: String,
145    version: Option<String>,
146    path: Option<PathBuf>,
147    locked: bool,
148    force: bool,
149    build: bool,
150    repository: Option<String>,
151}
152
153/// Default to the versioned prebuilt distribution protocol. Cargo builds are
154/// explicit (--build or --path) and run off the async worker.
155pub async fn run_async(args: &[String]) -> i32 {
156    if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
157        print_help();
158        return 0;
159    }
160    let options = match parse_args(args) {
161        Ok(options) => options,
162        Err(error) => {
163            eprintln!("error: {error}");
164            print_help();
165            return 2;
166        }
167    };
168    if let Err(error) = installed_native_packages_strict() {
169        eprintln!("error: cannot safely install while native package metadata is invalid: {error}");
170        return 1;
171    }
172    if options.build || options.path.is_some() {
173        let args = args.to_vec();
174        return match tokio::task::spawn_blocking(move || run(&args)).await {
175            Ok(code) => code,
176            Err(error) => {
177                eprintln!("error: extension build failed: {error}");
178                1
179            }
180        };
181    }
182    if crate::args::offline_env_enabled() {
183        eprintln!("error: prebuilt installation requires network access; use --build for an explicit Cargo build");
184        return 1;
185    }
186    match crate::prebuilt_extensions::install(
187        &options.package,
188        options.version.as_deref(),
189        options.repository.as_deref(),
190        options.force,
191    )
192    .await
193    {
194        Ok(()) => 0,
195        Err(error) => {
196            eprintln!("error: {error}");
197            1
198        }
199    }
200}
201
202#[derive(Debug, Deserialize)]
203struct CargoMetadata {
204    packages: Vec<CargoPackage>,
205}
206
207#[derive(Debug, Deserialize)]
208struct CargoPackage {
209    name: String,
210    version: String,
211    targets: Vec<CargoTarget>,
212}
213
214#[derive(Debug, Deserialize)]
215struct CargoTarget {
216    name: String,
217    crate_types: Vec<String>,
218}
219
220/// Run `rpi install ...`. This is intentionally synchronous: it is a short
221/// lived package operation and keeping Cargo's build output attached to the
222/// user's terminal makes failures actionable.
223pub fn run(args: &[String]) -> i32 {
224    if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
225        print_help();
226        return 0;
227    }
228    let options = match parse_args(args) {
229        Ok(options) => options,
230        Err(message) => {
231            eprintln!("error: {message}");
232            print_help();
233            return 2;
234        }
235    };
236
237    // Validate before Cargo work or extension-store mutations. A damaged file
238    // may contain the only record of installed artifacts, so treating it as
239    // an empty registry would make a later write destructive.
240    let records = match installed_native_packages_strict() {
241        Ok(records) => records,
242        Err(error) => {
243            eprintln!(
244                "error: cannot safely install while native package metadata is invalid: {error}"
245            );
246            return 1;
247        }
248    };
249    if !options.build && options.path.is_none() {
250        eprintln!(
251            "error: prebuilt installs use the async dispatcher; Cargo compilation requires --build"
252        );
253        return 2;
254    }
255
256    let temp = match tempfile::tempdir() {
257        Ok(temp) => temp,
258        Err(error) => {
259            eprintln!("error: could not create a temporary Cargo workspace: {error}");
260            return 1;
261        }
262    };
263    let manifest = temp.path().join("Cargo.toml");
264    if let Err(error) = write_manifest(&manifest, &options) {
265        eprintln!("error: could not prepare Cargo workspace: {error}");
266        return 1;
267    }
268
269    if let Err(error) = cargo_command("fetch", &manifest, &options, false) {
270        eprintln!("error: could not resolve `{}`: {error}", options.package);
271        return 1;
272    }
273
274    let metadata = match cargo_metadata(&manifest, &options) {
275        Ok(metadata) => metadata,
276        Err(error) => {
277            eprintln!("error: could not inspect `{}`: {error}", options.package);
278            return 1;
279        }
280    };
281    let package = match metadata
282        .packages
283        .iter()
284        .find(|package| package.name == options.package)
285    {
286        Some(package) => package,
287        None => {
288            eprintln!(
289                "error: Cargo did not resolve a package named `{}`",
290                options.package
291            );
292            return 1;
293        }
294    };
295    match should_install_version(
296        &package.name,
297        records
298            .iter()
299            .find(|p| p.name == package.name)
300            .map(|p| p.version.as_str()),
301        &package.version,
302        options.force,
303    ) {
304        Ok(true) => {}
305        Ok(false) => {
306            print_already_installed(&package.name, &package.version);
307            return 0;
308        }
309        Err(error) => {
310            eprintln!("error: {error}");
311            return 1;
312        }
313    }
314    let cdylib_targets: Vec<&CargoTarget> = package
315        .targets
316        .iter()
317        .filter(|target| target.crate_types.iter().any(|kind| kind == "cdylib"))
318        .collect();
319    if cdylib_targets.is_empty() {
320        eprintln!(
321            "error: `{}` is not an rpi extension crate; it has no `cdylib` target",
322            options.package
323        );
324        eprintln!("{CDYLIB_EXTENSION_HINT}");
325        return 1;
326    }
327
328    if let Err(error) = cargo_command("build", &manifest, &options, true) {
329        eprintln!("error: failed to build `{}`: {error}", options.package);
330        return 1;
331    }
332
333    let artifact_dir = temp.path().join("target").join("release");
334    let artifacts = match find_artifacts(&artifact_dir, &cdylib_targets) {
335        Ok(artifacts) => artifacts,
336        Err(error) => {
337            eprintln!("error: {error}");
338            return 1;
339        }
340    };
341
342    let agent_dir = match crate::config::agent_dir() {
343        Ok(dir) => dir,
344        Err(error) => {
345            eprintln!("error: could not resolve the rpi config directory: {error}");
346            return 1;
347        }
348    };
349    let destination = agent_dir.join("extensions");
350    if let Err(error) = std::fs::create_dir_all(&destination) {
351        eprintln!(
352            "error: could not create extension directory {}: {error}",
353            destination.display()
354        );
355        return 1;
356    }
357
358    let version = metadata
359        .packages
360        .iter()
361        .find(|candidate| candidate.name == options.package)
362        .map(|candidate| candidate.version.clone())
363        .unwrap_or_else(|| "0.0.0".to_string());
364    let record = InstalledNativePackage {
365        name: options.package.clone(),
366        version,
367        source: options
368            .path
369            .as_ref()
370            .map(|path| path.to_string_lossy().into_owned()),
371        prebuilt: None,
372        artifacts: Vec::new(),
373    };
374    let installed = match install_native_package_at(
375        &destination,
376        &agent_dir.join(NATIVE_PACKAGES_FILE),
377        record,
378        &artifacts,
379        options.force,
380    ) {
381        Ok(installed) => installed,
382        Err(error) => {
383            eprintln!("error: could not install `{}`: {error}", options.package);
384            return 1;
385        }
386    };
387    for target in installed {
388        println!("installed {}", target.display());
389    }
390    println!("rpi will load this extension on the next start.");
391    0
392}
393
394/// Remove a Rust-native extension installed by `rpi install`.
395///
396/// The install registry is authoritative for new installs. For metadata from
397/// older rpi versions, dynamic libraries whose normalized file stem matches
398/// the crate name are removed as a compatibility fallback.
399pub fn uninstall(args: &[String]) -> i32 {
400    if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
401        print_uninstall_help();
402        return 0;
403    }
404    let name = match parse_uninstall_name(args) {
405        Ok(name) => name,
406        Err(error) => {
407            eprintln!("error: {error}");
408            print_uninstall_help();
409            return 2;
410        }
411    };
412    let agent = match crate::config::agent_dir() {
413        Ok(path) => path,
414        Err(error) => {
415            eprintln!("error: could not resolve the rpi config directory: {error}");
416            return 1;
417        }
418    };
419    let extension_dir = agent.join("extensions");
420    let metadata_path = agent.join(NATIVE_PACKAGES_FILE);
421    let _lock = match NativePackageMutationLock::acquire(&metadata_path) {
422        Ok(lock) => lock,
423        Err(error) => {
424            eprintln!("error: cannot safely uninstall: {error}");
425            return 1;
426        }
427    };
428    let records = match read_native_package_registry_at(&metadata_path) {
429        Ok(registry) => registry.into_records(),
430        Err(error) => {
431            eprintln!(
432                "error: cannot safely uninstall while native package metadata is invalid: {error}"
433            );
434            return 1;
435        }
436    };
437    let had_record = records.iter().any(|record| record.name == name);
438    let wanted = normalize_name(&name);
439    let artifact_names: std::collections::HashSet<String> = records
440        .iter()
441        .filter(|record| record.name == name)
442        .flat_map(|record| record.artifacts.iter().cloned())
443        .collect();
444    let use_legacy_name_fallback = artifact_names.is_empty();
445    let mut artifact_targets = Vec::new();
446    if let Ok(entries) = std::fs::read_dir(&extension_dir) {
447        for entry in entries.flatten() {
448            let path = entry.path();
449            let is_artifact = artifact_names.contains(
450                &path
451                    .file_name()
452                    .map(|name| name.to_string_lossy().into_owned())
453                    .unwrap_or_default(),
454            ) || (use_legacy_name_fallback
455                && path
456                    .file_stem()
457                    .and_then(|stem| stem.to_str())
458                    .map(|stem| normalize_name(stem.trim_start_matches("lib")) == wanted)
459                    .unwrap_or(false));
460            if is_artifact && is_dynamic_library(&path) {
461                artifact_targets.push(path);
462            }
463        }
464    }
465    let mut remaining: Vec<_> = records
466        .into_iter()
467        .filter(|record| record.name != name)
468        .collect();
469    if had_record {
470        remaining.sort_by(|left, right| left.name.cmp(&right.name));
471    }
472    let mut replacements = Vec::new();
473    let mut removals = artifact_targets.clone();
474    if had_record {
475        if remaining.is_empty() {
476            removals.push(metadata_path.clone());
477        } else {
478            let registry = match serde_json::to_vec_pretty(&remaining) {
479                Ok(registry) => registry,
480                Err(error) => {
481                    eprintln!("error: could not serialize native package metadata: {error}");
482                    return 1;
483                }
484            };
485            let staged = match stage_native_bytes(&registry, &metadata_path) {
486                Ok(staged) => staged,
487                Err(error) => {
488                    eprintln!("error: could not stage native package metadata: {error}");
489                    return 1;
490                }
491            };
492            replacements.push(NativeReplacement {
493                staged,
494                target: metadata_path.clone(),
495            });
496        }
497    }
498    if let Err(error) = activate_native_transaction(&replacements, &removals, &mut |_, _| Ok(())) {
499        eprintln!("error: could not uninstall Rust extension {name}: {error}");
500        return 1;
501    }
502    for path in &artifact_targets {
503        println!("removed {}", path.display());
504    }
505    let removed = artifact_targets.len();
506    if removed == 0 && !had_record {
507        println!("Rust extension is not installed: {name}");
508        return 0;
509    }
510    println!("uninstalled Rust extension {name}");
511    0
512}
513
514fn parse_uninstall_name(args: &[String]) -> Result<String, String> {
515    let mut name = None;
516    for arg in args {
517        match arg.as_str() {
518            "--help" | "-h" => return Err("use `rpi uninstall --help` for usage".into()),
519            value if value.starts_with('-') => {
520                return Err(format!("unknown uninstall option `{value}`"))
521            }
522            value => {
523                if name.replace(value.to_string()).is_some() {
524                    return Err("uninstall accepts exactly one crate name".into());
525                }
526            }
527        }
528    }
529    let name = name.ok_or_else(|| "missing crate name".to_string())?;
530    if !valid_package_name(&name) {
531        return Err(format!("invalid Cargo package name `{name}`"));
532    }
533    Ok(name)
534}
535
536#[cfg(test)]
537fn write_native_packages_at(path: &Path, records: &[InstalledNativePackage]) -> Result<(), String> {
538    if records.is_empty() {
539        match std::fs::remove_file(path) {
540            Ok(()) => return Ok(()),
541            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
542            Err(error) => return Err(error.to_string()),
543        }
544    }
545    let parent = path
546        .parent()
547        .ok_or_else(|| "native package metadata has no parent".to_string())?;
548    std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
549    let data = serde_json::to_vec_pretty(records).map_err(|error| error.to_string())?;
550    std::fs::write(path, data).map_err(|error| error.to_string())
551}
552
553/// Read and validate the Rust-native extension registry without hiding errors.
554pub fn read_native_package_registry() -> Result<NativePackageRegistry, String> {
555    let path = crate::config::agent_dir()
556        .map_err(|error| error.to_string())?
557        .join(NATIVE_PACKAGES_FILE);
558    read_native_package_registry_at(&path)
559}
560
561fn read_native_package_registry_at(path: &Path) -> Result<NativePackageRegistry, String> {
562    let data = match std::fs::read(path) {
563        Ok(data) => data,
564        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
565            return Ok(NativePackageRegistry::Missing)
566        }
567        Err(error) => {
568            return Err(format!("could not read {}: {error}", path.display()));
569        }
570    };
571    serde_json::from_slice(&data)
572        .map(NativePackageRegistry::Valid)
573        .map_err(|error| format!("invalid JSON or schema in {}: {error}", path.display()))
574}
575
576/// Read the registry for best-effort discovery and startup update notices.
577///
578/// Missing, unreadable, and malformed registries all produce an empty list so
579/// startup remains safe. Install and uninstall operations instead use
580/// [`read_native_package_registry`] and fail closed on an invalid file.
581pub fn installed_native_packages() -> Vec<InstalledNativePackage> {
582    installed_native_packages_strict().unwrap_or_default()
583}
584
585/// Read the installed package list for a command that may mutate package
586/// state. Unlike startup discovery, callers must surface registry errors and
587/// abort before touching any package store.
588pub(crate) fn installed_native_packages_strict() -> Result<Vec<InstalledNativePackage>, String> {
589    read_native_package_registry().map(NativePackageRegistry::into_records)
590}
591
592#[cfg(test)]
593fn record_native_package_at(path: &Path, record: &InstalledNativePackage) -> Result<(), String> {
594    let mut records = read_native_package_registry_at(path)?.into_records();
595    if let Some(existing) = records.iter_mut().find(|item| item.name == record.name) {
596        *existing = record.clone();
597    } else {
598        records.push(record.clone());
599    }
600    records.sort_by(|left, right| left.name.cmp(&right.name));
601    write_native_packages_at(path, &records)
602}
603
604struct StagedNativeFile {
605    path: PathBuf,
606}
607
608impl Drop for StagedNativeFile {
609    fn drop(&mut self) {
610        let _ = std::fs::remove_file(&self.path);
611    }
612}
613
614struct NativeReplacement {
615    staged: StagedNativeFile,
616    target: PathBuf,
617}
618
619struct NativeBackup {
620    target: PathBuf,
621    backup: PathBuf,
622}
623
624pub(crate) fn install_native_package_at(
625    destination: &Path,
626    metadata_path: &Path,
627    record: InstalledNativePackage,
628    artifacts: &[PathBuf],
629    force: bool,
630) -> Result<Vec<PathBuf>, String> {
631    install_native_package_at_with_hook(
632        destination,
633        metadata_path,
634        record,
635        artifacts,
636        force,
637        |_, _| Ok(()),
638    )
639}
640
641fn install_native_package_at_with_hook(
642    destination: &Path,
643    metadata_path: &Path,
644    mut record: InstalledNativePackage,
645    artifacts: &[PathBuf],
646    force: bool,
647    mut before_activate: impl FnMut(usize, &Path) -> Result<(), String>,
648) -> Result<Vec<PathBuf>, String> {
649    std::fs::create_dir_all(destination).map_err(|error| {
650        format!(
651            "could not create extension directory {}: {error}",
652            destination.display()
653        )
654    })?;
655    let metadata_parent = metadata_path
656        .parent()
657        .ok_or_else(|| "native package metadata has no parent".to_string())?;
658    std::fs::create_dir_all(metadata_parent).map_err(|error| error.to_string())?;
659    let _lock = NativePackageMutationLock::acquire(metadata_path)?;
660    let mut records = read_native_package_registry_at(metadata_path)?.into_records();
661    let previous = records
662        .iter()
663        .find(|candidate| candidate.name == record.name)
664        .cloned();
665
666    if !should_install_version(
667        &record.name,
668        previous.as_ref().map(|p| p.version.as_str()),
669        &record.version,
670        force,
671    )? {
672        print_already_installed(&record.name, &record.version);
673        return Ok(Vec::new());
674    }
675
676    let mut new_artifacts = Vec::with_capacity(artifacts.len());
677    let mut new_keys = HashSet::new();
678    for artifact in artifacts {
679        let metadata = std::fs::symlink_metadata(artifact).map_err(|error| {
680            format!(
681                "could not inspect built artifact {}: {error}",
682                artifact.display()
683            )
684        })?;
685        if !metadata.is_file() || metadata.file_type().is_symlink() {
686            return Err(format!(
687                "built artifact is not a regular file: {}",
688                artifact.display()
689            ));
690        }
691        let name = artifact_file_name(artifact)?;
692        let key = native_artifact_identity(&name);
693        if !new_keys.insert(key) {
694            return Err(format!("duplicate built artifact name `{name}`"));
695        }
696        new_artifacts.push((name, artifact.clone()));
697    }
698    if new_artifacts.is_empty() {
699        return Err("Cargo produced no installable cdylib artifacts".to_string());
700    }
701
702    for other in records
703        .iter()
704        .filter(|candidate| candidate.name != record.name)
705    {
706        for owned in &other.artifacts {
707            checked_registered_artifact_path(destination, owned)?;
708            if new_keys.contains(&native_artifact_identity(owned)) {
709                return Err(format!(
710                    "artifact `{owned}` is already owned by installed package `{}`",
711                    other.name
712                ));
713            }
714        }
715    }
716
717    let mut stale_targets = Vec::new();
718    let mut owned_keys = HashSet::new();
719    if let Some(previous) = &previous {
720        if previous.artifacts.is_empty() {
721            let wanted = normalize_name(&record.name);
722            for entry in std::fs::read_dir(destination).map_err(|error| {
723                format!(
724                    "could not inspect extension directory {}: {error}",
725                    destination.display()
726                )
727            })? {
728                let entry = entry.map_err(|error| {
729                    format!("could not inspect installed extension artifact: {error}")
730                })?;
731                let path = entry.path();
732                let matches_legacy_name = path
733                    .file_stem()
734                    .and_then(|stem| stem.to_str())
735                    .map(|stem| normalize_name(stem.trim_start_matches("lib")) == wanted)
736                    .unwrap_or(false);
737                if matches_legacy_name && is_dynamic_library(&path) {
738                    owned_keys.insert(native_artifact_identity(&artifact_file_name(&path)?));
739                    stale_targets.push(path);
740                }
741            }
742        } else {
743            for old_name in &previous.artifacts {
744                let old_path = checked_registered_artifact_path(destination, old_name)?;
745                owned_keys.insert(native_artifact_identity(old_name));
746                if !new_keys.contains(&native_artifact_identity(old_name)) {
747                    stale_targets.push(old_path);
748                }
749            }
750        }
751    }
752
753    let installed_targets = new_artifacts
754        .iter()
755        .map(|(name, _)| destination.join(name))
756        .collect::<Vec<_>>();
757    if !force {
758        for target in &installed_targets {
759            if owned_keys.contains(&native_artifact_identity(&artifact_file_name(target)?)) {
760                continue;
761            }
762            match std::fs::symlink_metadata(target) {
763                Ok(_) => {
764                    return Err(format!(
765                        "extension {} already exists; use --force to replace it",
766                        target.display()
767                    ))
768                }
769                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
770                Err(error) => {
771                    return Err(format!(
772                        "could not inspect extension target {}: {error}",
773                        target.display()
774                    ))
775                }
776            }
777        }
778    }
779
780    record.artifacts = new_artifacts.iter().map(|(name, _)| name.clone()).collect();
781    if let Some(existing) = records
782        .iter_mut()
783        .find(|candidate| candidate.name == record.name)
784    {
785        *existing = record;
786    } else {
787        records.push(record);
788    }
789    records.sort_by(|left, right| left.name.cmp(&right.name));
790
791    let mut replacements = Vec::with_capacity(new_artifacts.len() + 1);
792    for ((_, source), target) in new_artifacts.iter().zip(&installed_targets) {
793        replacements.push(NativeReplacement {
794            staged: stage_native_artifact(source, target)?,
795            target: target.clone(),
796        });
797    }
798    let registry = serde_json::to_vec_pretty(&records).map_err(|error| error.to_string())?;
799    replacements.push(NativeReplacement {
800        staged: stage_native_bytes(&registry, metadata_path)?,
801        target: metadata_path.to_path_buf(),
802    });
803
804    activate_native_transaction(&replacements, &stale_targets, &mut before_activate)?;
805    Ok(installed_targets)
806}
807
808fn artifact_file_name(path: &Path) -> Result<String, String> {
809    let name = path
810        .file_name()
811        .and_then(|name| name.to_str())
812        .ok_or_else(|| format!("artifact has no UTF-8 file name: {}", path.display()))?;
813    checked_registered_artifact_path(Path::new("."), name)?;
814    Ok(name.to_string())
815}
816
817fn checked_registered_artifact_path(destination: &Path, name: &str) -> Result<PathBuf, String> {
818    let relative = Path::new(name);
819    let is_single_component = relative.components().count() == 1
820        && relative.file_name().and_then(|value| value.to_str()) == Some(name);
821    if !is_single_component || !is_dynamic_library(relative) {
822        return Err(format!("unsafe native package artifact name `{name}`"));
823    }
824    Ok(destination.join(relative))
825}
826
827fn native_artifact_identity(name: &str) -> String {
828    if cfg!(windows) {
829        name.to_ascii_lowercase()
830    } else {
831        name.to_string()
832    }
833}
834
835fn transaction_path_identity(path: &Path) -> String {
836    let value = path.to_string_lossy().into_owned();
837    if cfg!(windows) {
838        value.to_ascii_lowercase()
839    } else {
840        value
841    }
842}
843
844fn adjacent_transaction_path(target: &Path, kind: &str) -> Result<PathBuf, String> {
845    let parent = target
846        .parent()
847        .ok_or_else(|| format!("transaction target has no parent: {}", target.display()))?;
848    let leaf = target
849        .file_name()
850        .and_then(|name| name.to_str())
851        .unwrap_or("artifact");
852    for _ in 0..8 {
853        let candidate = parent.join(format!(
854            ".{leaf}.rpi-{kind}-{}",
855            uuid::Uuid::new_v4().simple()
856        ));
857        match std::fs::symlink_metadata(&candidate) {
858            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(candidate),
859            Ok(_) => {}
860            Err(error) => {
861                return Err(format!(
862                    "could not inspect transaction path {}: {error}",
863                    candidate.display()
864                ))
865            }
866        }
867    }
868    Err(format!(
869        "could not allocate a transaction path beside {}",
870        target.display()
871    ))
872}
873
874fn stage_native_artifact(source: &Path, target: &Path) -> Result<StagedNativeFile, String> {
875    let stage = adjacent_transaction_path(target, "stage")?;
876    let result = (|| {
877        let source_metadata = std::fs::symlink_metadata(source).map_err(|error| {
878            format!(
879                "could not inspect built artifact {}: {error}",
880                source.display()
881            )
882        })?;
883        let mut input = std::fs::File::open(source).map_err(|error| {
884            format!(
885                "could not open built artifact {}: {error}",
886                source.display()
887            )
888        })?;
889        let mut output = std::fs::OpenOptions::new()
890            .write(true)
891            .create_new(true)
892            .open(&stage)
893            .map_err(|error| {
894                format!(
895                    "could not create staged artifact {}: {error}",
896                    stage.display()
897                )
898            })?;
899        std::io::copy(&mut input, &mut output)
900            .and_then(|_| output.sync_all())
901            .map_err(|error| {
902                format!(
903                    "could not write staged artifact {} beside {}: {error}",
904                    stage.display(),
905                    target.display()
906                )
907            })?;
908        drop(output);
909        std::fs::set_permissions(&stage, source_metadata.permissions()).map_err(|error| {
910            format!(
911                "could not preserve permissions on staged artifact {}: {error}",
912                stage.display()
913            )
914        })
915    })();
916    if let Err(error) = result {
917        let _ = std::fs::remove_file(&stage);
918        return Err(error);
919    }
920    Ok(StagedNativeFile { path: stage })
921}
922fn stage_native_bytes(bytes: &[u8], target: &Path) -> Result<StagedNativeFile, String> {
923    let stage = adjacent_transaction_path(target, "stage")?;
924    let result = (|| {
925        let mut file = std::fs::OpenOptions::new()
926            .write(true)
927            .create_new(true)
928            .open(&stage)
929            .map_err(|error| {
930                format!(
931                    "could not create staged registry {}: {error}",
932                    stage.display()
933                )
934            })?;
935        file.write_all(bytes)
936            .and_then(|()| file.sync_all())
937            .map_err(|error| {
938                format!(
939                    "could not flush staged registry {}: {error}",
940                    stage.display()
941                )
942            })
943    })();
944    if let Err(error) = result {
945        let _ = std::fs::remove_file(&stage);
946        return Err(error);
947    }
948    Ok(StagedNativeFile { path: stage })
949}
950
951fn activate_native_transaction(
952    replacements: &[NativeReplacement],
953    removals: &[PathBuf],
954    before_activate: &mut impl FnMut(usize, &Path) -> Result<(), String>,
955) -> Result<(), String> {
956    let mut affected = Vec::new();
957    let mut seen = HashSet::new();
958    for target in replacements
959        .iter()
960        .map(|replacement| &replacement.target)
961        .chain(removals.iter())
962    {
963        if seen.insert(transaction_path_identity(target)) {
964            affected.push(target.clone());
965        }
966    }
967
968    for target in &affected {
969        match std::fs::symlink_metadata(target) {
970            Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {}
971            Ok(_) => {
972                return Err(format!(
973                    "refusing to replace non-regular native package file {}",
974                    target.display()
975                ))
976            }
977            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
978            Err(error) => {
979                return Err(format!(
980                    "could not inspect native package file {}: {error}",
981                    target.display()
982                ))
983            }
984        }
985    }
986
987    let mut backups = Vec::new();
988    for target in &affected {
989        if !target.exists() {
990            continue;
991        }
992        let backup = match adjacent_transaction_path(target, "backup") {
993            Ok(backup) => backup,
994            Err(error) => {
995                let rollback = rollback_native_transaction(&[], &backups);
996                return Err(transaction_error(error, rollback));
997            }
998        };
999        if let Err(error) = std::fs::rename(target, &backup) {
1000            let rollback = rollback_native_transaction(&[], &backups);
1001            return Err(transaction_error(
1002                format!("could not back up {}: {error}", target.display()),
1003                rollback,
1004            ));
1005        }
1006        backups.push(NativeBackup {
1007            target: target.clone(),
1008            backup,
1009        });
1010    }
1011
1012    let mut activated = Vec::new();
1013    for (index, replacement) in replacements.iter().enumerate() {
1014        let activation = before_activate(index, &replacement.target).and_then(|()| {
1015            std::fs::rename(&replacement.staged.path, &replacement.target).map_err(|error| {
1016                format!(
1017                    "could not activate replacement {}: {error}",
1018                    replacement.target.display()
1019                )
1020            })
1021        });
1022        if let Err(error) = activation {
1023            let rollback = rollback_native_transaction(&activated, &backups);
1024            return Err(transaction_error(error, rollback));
1025        }
1026        activated.push(replacement.target.clone());
1027    }
1028
1029    for backup in backups {
1030        if let Err(error) = std::fs::remove_file(&backup.backup) {
1031            eprintln!(
1032                "warning: native package updated but backup {} could not be removed: {error}",
1033                backup.backup.display()
1034            );
1035        }
1036    }
1037    Ok(())
1038}
1039
1040fn rollback_native_transaction(
1041    activated: &[PathBuf],
1042    backups: &[NativeBackup],
1043) -> Result<(), String> {
1044    let mut errors = Vec::new();
1045    for target in activated.iter().rev() {
1046        match std::fs::remove_file(target) {
1047            Ok(()) => {}
1048            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1049            Err(error) => errors.push(format!("could not remove {}: {error}", target.display())),
1050        }
1051    }
1052    for backup in backups.iter().rev() {
1053        if let Err(error) = std::fs::rename(&backup.backup, &backup.target) {
1054            errors.push(format!(
1055                "could not restore {} from {}: {error}",
1056                backup.target.display(),
1057                backup.backup.display()
1058            ));
1059        }
1060    }
1061    if errors.is_empty() {
1062        Ok(())
1063    } else {
1064        Err(errors.join("; "))
1065    }
1066}
1067
1068fn transaction_error(error: String, rollback: Result<(), String>) -> String {
1069    match rollback {
1070        Ok(()) => error,
1071        Err(rollback) => format!("{error}; rollback also failed: {rollback}"),
1072    }
1073}
1074
1075fn parse_args(args: &[String]) -> Result<InstallOptions, String> {
1076    let mut package = None;
1077    let mut version = None;
1078    let mut path = None;
1079    let mut locked = false;
1080    let mut force = false;
1081    let mut build = false;
1082    let mut repository = None;
1083    let mut i = 0;
1084    while i < args.len() {
1085        match args[i].as_str() {
1086            "--help" | "-h" => return Err(help_requested().to_string()),
1087            "--offline" => {} // app normalizes this flag before dispatch
1088            "--locked" => locked = true,
1089            "--force" | "-f" => force = true,
1090            "--build" => build = true,
1091            "--github" => {
1092                i += 1;
1093                repository = Some(value(args, i, "--github")?);
1094            }
1095            "--version" | "-V" => {
1096                i += 1;
1097                version = Some(value(args, i, "--version")?);
1098            }
1099            "--path" => {
1100                i += 1;
1101                path = Some(PathBuf::from(value(args, i, "--path")?));
1102            }
1103            value if value.starts_with('-') => {
1104                return Err(format!("unknown install option `{value}`"));
1105            }
1106            value => {
1107                if package.replace(value.to_string()).is_some() {
1108                    return Err("install accepts exactly one crate name".to_string());
1109                }
1110            }
1111        }
1112        i += 1;
1113    }
1114    if path.is_some() && version.is_some() {
1115        return Err("--path and --version cannot be used together".to_string());
1116    }
1117    if repository.is_some() && (build || path.is_some()) {
1118        return Err("--github cannot be combined with --build or --path".into());
1119    }
1120    if locked && !build && path.is_none() {
1121        return Err("--locked requires --build or --path".into());
1122    }
1123    let package = match package {
1124        Some(package) => package,
1125        None => {
1126            let path = path
1127                .as_deref()
1128                .ok_or_else(|| "missing crate name (or use --path <directory>)".to_string())?;
1129            package_name_from_manifest(path)?
1130        }
1131    };
1132    if !valid_package_name(&package) {
1133        return Err(format!("invalid Cargo package name `{package}`"));
1134    }
1135    Ok(InstallOptions {
1136        package,
1137        version,
1138        path,
1139        locked,
1140        force,
1141        build,
1142        repository,
1143    })
1144}
1145
1146fn value(args: &[String], index: usize, flag: &str) -> Result<String, String> {
1147    args.get(index)
1148        .filter(|value| !value.starts_with('-'))
1149        .cloned()
1150        .ok_or_else(|| format!("{flag} requires a value"))
1151}
1152
1153fn package_name_from_manifest(path: &Path) -> Result<String, String> {
1154    let manifest = if path.is_dir() {
1155        path.join("Cargo.toml")
1156    } else {
1157        path.to_path_buf()
1158    };
1159    let output = Command::new("cargo")
1160        .args([
1161            "metadata",
1162            "--no-deps",
1163            "--format-version",
1164            "1",
1165            "--manifest-path",
1166        ])
1167        .arg(&manifest)
1168        .output()
1169        .map_err(|error| format!("could not run cargo metadata: {error}"))?;
1170    if !output.status.success() {
1171        return Err(format!(
1172            "could not inspect {}: {}",
1173            manifest.display(),
1174            String::from_utf8_lossy(&output.stderr).trim()
1175        ));
1176    }
1177    let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)
1178        .map_err(|error| format!("cargo metadata returned invalid JSON: {error}"))?;
1179    let manifest = std::fs::canonicalize(&manifest)
1180        .map_err(|error| format!("could not resolve {}: {error}", manifest.display()))?;
1181    metadata
1182        .get("packages")
1183        .and_then(serde_json::Value::as_array)
1184        .and_then(|packages| {
1185            packages.iter().find_map(|package| {
1186                let package_manifest = package.get("manifest_path")?.as_str()?;
1187                let package_manifest = std::fs::canonicalize(package_manifest).ok()?;
1188                (package_manifest == manifest)
1189                    .then(|| package.get("name")?.as_str().map(str::to_owned))
1190                    .flatten()
1191            })
1192        })
1193        .filter(|name| valid_package_name(name))
1194        .ok_or_else(|| format!("{} has no valid [package].name", manifest.display()))
1195}
1196
1197fn valid_package_name(name: &str) -> bool {
1198    !name.is_empty()
1199        && name
1200            .bytes()
1201            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1202}
1203
1204fn help_requested() -> &'static str {
1205    "use `rpi install --help` for usage"
1206}
1207
1208pub fn print_help() {
1209    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.");
1210}
1211
1212fn print_uninstall_help() {
1213    println!(
1214        "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"
1215    );
1216}
1217
1218fn write_manifest(path: &Path, options: &InstallOptions) -> Result<(), String> {
1219    let source_dir = path
1220        .parent()
1221        .ok_or_else(|| "temporary workspace has no parent directory".to_string())?
1222        .join("src");
1223    std::fs::create_dir_all(&source_dir).map_err(|error| error.to_string())?;
1224    // Cargo requires the temporary root package to have a target even though
1225    // rpi never builds it; the requested extension is built as a dependency.
1226    std::fs::write(source_dir.join("lib.rs"), "pub fn installer_marker() {}\n")
1227        .map_err(|error| error.to_string())?;
1228    let dependency = if let Some(local_path) = &options.path {
1229        let absolute = if local_path.is_absolute() {
1230            local_path.clone()
1231        } else {
1232            std::env::current_dir()
1233                .map_err(|error| error.to_string())?
1234                .join(local_path)
1235        };
1236        format!(
1237            "rpi_extension_dep = {{ package = {:?}, path = {:?} }}",
1238            options.package,
1239            absolute.display().to_string()
1240        )
1241    } else {
1242        let version = options.version.as_deref().unwrap_or("*");
1243        format!(
1244            "rpi_extension_dep = {{ package = {:?}, version = {:?} }}",
1245            options.package, version
1246        )
1247    };
1248    let contents = format!(
1249        "[package]\nname = \"{INSTALLER_MANIFEST}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n\n[dependencies]\n{dependency}\n"
1250    );
1251    std::fs::write(path, contents).map_err(|error| error.to_string())
1252}
1253
1254fn cargo_command(
1255    subcommand: &str,
1256    manifest: &Path,
1257    options: &InstallOptions,
1258    build: bool,
1259) -> Result<(), String> {
1260    let mut command = Command::new("cargo");
1261    command.arg(subcommand).arg("--manifest-path").arg(manifest);
1262    if build {
1263        command
1264            .arg("--package")
1265            .arg(&options.package)
1266            .arg("--release")
1267            .arg("--target-dir")
1268            .arg(manifest.parent().unwrap().join("target"));
1269    }
1270    if options.locked {
1271        command.arg("--locked");
1272    }
1273    if crate::args::offline_env_enabled() {
1274        command.arg("--offline");
1275    }
1276    let status = command
1277        .stdin(Stdio::inherit())
1278        .stdout(Stdio::inherit())
1279        .stderr(Stdio::inherit())
1280        .status()
1281        .map_err(|error| format!("could not execute cargo: {error}"))?;
1282    if status.success() {
1283        Ok(())
1284    } else {
1285        Err(format!("cargo {subcommand} exited with {status}"))
1286    }
1287}
1288
1289fn cargo_metadata(manifest: &Path, options: &InstallOptions) -> Result<CargoMetadata, String> {
1290    let mut command = Command::new("cargo");
1291    command
1292        .arg("metadata")
1293        .arg("--format-version")
1294        .arg("1")
1295        .arg("--manifest-path")
1296        .arg(manifest);
1297    if options.locked {
1298        command.arg("--locked");
1299    }
1300    if crate::args::offline_env_enabled() {
1301        command.arg("--offline");
1302    }
1303    let output = command
1304        .output()
1305        .map_err(|error| format!("could not execute cargo: {error}"))?;
1306    if !output.status.success() {
1307        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
1308    }
1309    serde_json::from_slice(&output.stdout)
1310        .map_err(|error| format!("invalid cargo metadata: {error}"))
1311}
1312
1313fn find_artifacts(release_dir: &Path, targets: &[&CargoTarget]) -> Result<Vec<PathBuf>, String> {
1314    let mut artifacts = Vec::new();
1315    for target in targets {
1316        let wanted = normalize_name(&target.name);
1317        let mut matches = Vec::new();
1318        for dir in [release_dir.to_path_buf(), release_dir.join("deps")] {
1319            let entries = std::fs::read_dir(&dir).map_err(|error| {
1320                format!("could not inspect build output {}: {error}", dir.display())
1321            })?;
1322            for entry in entries.flatten() {
1323                let path = entry.path();
1324                if !is_dynamic_library(&path) {
1325                    continue;
1326                }
1327                let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
1328                    continue;
1329                };
1330                let normalized = normalize_name(stem.trim_start_matches("lib"));
1331                if normalized == wanted {
1332                    matches.push(path);
1333                }
1334            }
1335        }
1336        matches.sort_by_key(|path| path.components().count());
1337        let artifact = matches.into_iter().next().ok_or_else(|| {
1338            format!(
1339                "Cargo built `{}` but no cdylib artifact was found in {}",
1340                target.name,
1341                release_dir.display()
1342            )
1343        })?;
1344        artifacts.push(artifact);
1345    }
1346    Ok(artifacts)
1347}
1348
1349fn normalize_name(name: &str) -> String {
1350    name.replace('-', "_").to_ascii_lowercase()
1351}
1352
1353fn is_dynamic_library(path: &Path) -> bool {
1354    matches!(
1355        path.extension()
1356            .and_then(|extension| extension.to_str())
1357            .map(|extension| extension.to_ascii_lowercase())
1358            .as_deref(),
1359        Some("dll" | "so" | "dylib" | "pyd")
1360    )
1361}
1362
1363#[cfg(test)]
1364mod tests {
1365    use super::*;
1366
1367    fn args(values: &[&str]) -> Vec<String> {
1368        values.iter().map(|value| value.to_string()).collect()
1369    }
1370
1371    fn native_record(name: &str) -> InstalledNativePackage {
1372        InstalledNativePackage {
1373            name: name.to_string(),
1374            version: "1.2.3".to_string(),
1375            source: None,
1376            prebuilt: None,
1377            artifacts: vec![format!("{name}.dll")],
1378        }
1379    }
1380
1381    #[test]
1382    fn install_version_policy_allows_upgrades_and_requires_force_for_downgrades() {
1383        assert!(should_install_version("demo", None, "1.0.0", false).unwrap());
1384        for (current, requested) in [
1385            ("0.1.11", "0.1.12"),
1386            ("1.0.0-beta.1", "1.0.0"),
1387            ("1.0.0-beta.1", "1.0.0-beta.2"),
1388        ] {
1389            assert!(should_install_version("demo", Some(current), requested, false).unwrap());
1390            let error =
1391                should_install_version("demo", Some(requested), current, false).unwrap_err();
1392            assert!(error.contains("--force to downgrade"), "{error}");
1393            assert!(should_install_version("demo", Some(requested), current, true).unwrap());
1394        }
1395        for requested in ["1.2.3", "1.2.3+new-build"] {
1396            assert!(!should_install_version("demo", Some("1.2.3"), requested, false).unwrap());
1397            assert!(should_install_version("demo", Some("1.2.3"), requested, true).unwrap());
1398        }
1399        assert!(should_install_version("demo", Some("unknown"), "1.0.0", false).is_err());
1400        assert!(should_install_version("demo", Some("unknown"), "1.0.0", true).unwrap());
1401    }
1402
1403    #[test]
1404    fn native_install_same_version_is_unchanged_and_force_reinstalls_or_downgrades() {
1405        let temp = tempfile::tempdir().unwrap();
1406        let destination = temp.path().join("extensions");
1407        let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
1408        let build = temp.path().join("demo.dll");
1409        std::fs::create_dir_all(&destination).unwrap();
1410        std::fs::write(destination.join("demo.dll"), b"installed").unwrap();
1411        let record = native_record("demo");
1412        write_native_packages_at(&metadata, &[record.clone()]).unwrap();
1413        let original_registry = std::fs::read(&metadata).unwrap();
1414        std::fs::write(&build, b"rebuilt").unwrap();
1415
1416        assert!(install_native_package_at(
1417            &destination,
1418            &metadata,
1419            record.clone(),
1420            &[build.clone()],
1421            false,
1422        )
1423        .unwrap()
1424        .is_empty());
1425        assert_eq!(
1426            std::fs::read(destination.join("demo.dll")).unwrap(),
1427            b"installed"
1428        );
1429        assert_eq!(std::fs::read(&metadata).unwrap(), original_registry);
1430        let mut older = record.clone();
1431        older.version = "1.0.0".into();
1432        let error = install_native_package_at(
1433            &destination,
1434            &metadata,
1435            older.clone(),
1436            &[build.clone()],
1437            false,
1438        )
1439        .unwrap_err();
1440        assert!(error.contains("--force to downgrade"));
1441        assert_eq!(
1442            std::fs::read(destination.join("demo.dll")).unwrap(),
1443            b"installed"
1444        );
1445        assert_eq!(std::fs::read(&metadata).unwrap(), original_registry);
1446
1447        for forced in [record, older] {
1448            install_native_package_at(
1449                &destination,
1450                &metadata,
1451                forced.clone(),
1452                &[build.clone()],
1453                true,
1454            )
1455            .unwrap();
1456            assert_eq!(
1457                std::fs::read(destination.join("demo.dll")).unwrap(),
1458                b"rebuilt"
1459            );
1460            let records = read_native_package_registry_at(&metadata)
1461                .unwrap()
1462                .into_records();
1463            assert_eq!(records[0].version, forced.version);
1464        }
1465        assert_no_native_transaction_files(&destination);
1466    }
1467
1468    #[test]
1469    fn native_upgrade_does_not_overwrite_unowned_artifacts() {
1470        let temp = tempfile::tempdir().unwrap();
1471        let destination = temp.path().join("extensions");
1472        let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
1473        std::fs::create_dir_all(&destination).unwrap();
1474        std::fs::write(destination.join("demo.dll"), b"installed").unwrap();
1475        std::fs::write(destination.join("unowned.dll"), b"unowned").unwrap();
1476        write_native_packages_at(&metadata, &[native_record("demo")]).unwrap();
1477        let original_registry = std::fs::read(&metadata).unwrap();
1478        let artifact = temp.path().join("unowned.dll");
1479        std::fs::write(&artifact, b"new").unwrap();
1480        let mut upgraded = native_record("demo");
1481        upgraded.version = "2.0.0".into();
1482        assert!(
1483            install_native_package_at(&destination, &metadata, upgraded, &[artifact], false,)
1484                .unwrap_err()
1485                .contains("already exists")
1486        );
1487        assert_eq!(
1488            std::fs::read(destination.join("demo.dll")).unwrap(),
1489            b"installed"
1490        );
1491        assert_eq!(
1492            std::fs::read(destination.join("unowned.dll")).unwrap(),
1493            b"unowned"
1494        );
1495        assert_eq!(std::fs::read(&metadata).unwrap(), original_registry);
1496        assert_no_native_transaction_files(&destination);
1497    }
1498
1499    struct TempAgent {
1500        _guard: std::sync::MutexGuard<'static, ()>,
1501        temp: tempfile::TempDir,
1502        previous: Option<std::ffi::OsString>,
1503    }
1504
1505    impl TempAgent {
1506        fn new() -> Self {
1507            let guard = crate::config::test_support::env_lock()
1508                .lock()
1509                .unwrap_or_else(|poisoned| poisoned.into_inner());
1510            let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
1511            let temp = tempfile::tempdir().unwrap();
1512            std::env::set_var(crate::config::CONFIG_DIR_ENV, temp.path());
1513            Self {
1514                _guard: guard,
1515                temp,
1516                previous,
1517            }
1518        }
1519
1520        fn metadata_path(&self) -> PathBuf {
1521            self.temp.path().join(NATIVE_PACKAGES_FILE)
1522        }
1523    }
1524
1525    impl Drop for TempAgent {
1526        fn drop(&mut self) {
1527            match self.previous.take() {
1528                Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
1529                None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
1530            }
1531        }
1532    }
1533
1534    #[test]
1535    fn parses_registry_package_and_options() {
1536        let parsed = parse_args(&args(&["my-extension", "--version", "1.2.3", "--force"])).unwrap();
1537        assert_eq!(parsed.package, "my-extension");
1538        assert_eq!(parsed.version.as_deref(), Some("1.2.3"));
1539        assert!(parsed.force);
1540    }
1541
1542    #[test]
1543    fn github_override_and_source_build_are_explicit_options() {
1544        let custom = parse_args(&args(&[
1545            "my-extension",
1546            "--github",
1547            "publisher/monorepo",
1548            "--version",
1549            "1.2.3",
1550        ]))
1551        .unwrap();
1552        assert_eq!(custom.repository.as_deref(), Some("publisher/monorepo"));
1553        assert_eq!(custom.version.as_deref(), Some("1.2.3"));
1554        assert!(!custom.build);
1555        let custom_url = parse_args(&args(&[
1556            "my-extension",
1557            "--github",
1558            "https://github.com/publisher/monorepo",
1559        ]))
1560        .unwrap();
1561        assert!(custom_url.repository.is_some());
1562        assert!(
1563            parse_args(&args(&["my-extension", "--build", "--locked"]))
1564                .unwrap()
1565                .build
1566        );
1567        assert!(parse_args(&args(&["my-extension", "--offline"])).is_ok());
1568        assert!(parse_args(&args(&[
1569            "my-extension",
1570            "--github",
1571            "owner/repo",
1572            "--build"
1573        ]))
1574        .is_err());
1575        assert!(parse_args(&args(&[
1576            "my-extension",
1577            "--github",
1578            "owner/repo",
1579            "--path",
1580            "."
1581        ]))
1582        .is_err());
1583        assert!(parse_args(&args(&["my-extension", "--locked"])).is_err());
1584    }
1585
1586    #[test]
1587    fn synchronous_source_backend_cannot_silently_compile_a_prebuilt_request() {
1588        let _agent = TempAgent::new();
1589        assert_eq!(run(&args(&["demo"])), 2);
1590        assert_eq!(
1591            run(&args(&[
1592                "demo",
1593                "--github",
1594                "publisher/repo",
1595                "--version",
1596                "1.2.3"
1597            ])),
1598            2
1599        );
1600    }
1601
1602    #[test]
1603    fn uninstall_prebuilt_package_uses_its_recorded_library_name() {
1604        let agent = TempAgent::new();
1605        let directory = agent.temp.path().join("extensions");
1606        std::fs::create_dir_all(&directory).unwrap();
1607        std::fs::write(directory.join("different_library.dll"), b"prebuilt library").unwrap();
1608        let record: InstalledNativePackage = serde_json::from_value(serde_json::json!({
1609            "name":"demo", "version":"1.2.3", "source":"prebuilt:https://github.com/owner/repo",
1610            "artifacts":["different_library.dll"],
1611            "prebuilt":{"repository":"https://github.com/owner/repo", "target":"x86_64-pc-windows-msvc",
1612                "manifest_sha256":"a".repeat(64),"source_commit":"b".repeat(40),"sdk_version":"0.3.23","plugin_abi":4,
1613                "archive_sha256":"c".repeat(64),"library_sha256":"d".repeat(64),"custom_repository":true}
1614        })).unwrap();
1615        write_native_packages_at(&agent.metadata_path(), &[record]).unwrap();
1616        assert_eq!(uninstall(&args(&["demo"])), 0);
1617        assert!(!directory.join("different_library.dll").exists());
1618        assert!(installed_native_packages_strict().unwrap().is_empty());
1619    }
1620
1621    #[test]
1622    fn parses_local_package() {
1623        let parsed = parse_args(&args(&["--path", "../extension", "my-extension"])).unwrap();
1624        assert_eq!(parsed.path, Some(PathBuf::from("../extension")));
1625    }
1626
1627    #[test]
1628    fn local_path_can_infer_package_name() {
1629        let parsed = parse_args(&args(&["--path", ".", "--force"])).unwrap();
1630        assert!(!parsed.package.is_empty());
1631        assert!(valid_package_name(&parsed.package));
1632        assert!(parsed.force);
1633    }
1634
1635    #[test]
1636    fn rejects_non_extension_options_and_invalid_names() {
1637        assert!(parse_args(&args(&["my.extension"])).is_err());
1638        assert!(parse_args(&args(&["my-extension", "--unknown"])).is_err());
1639        assert!(parse_args(&args(&["my-extension", "--path", ".", "--version", "1"])).is_err());
1640    }
1641
1642    #[test]
1643    fn cdylib_hint_recommends_unified_and_documents_legacy_compatibility() {
1644        assert!(CDYLIB_EXTENSION_HINT.contains("rpi_plugin_register"));
1645        assert!(CDYLIB_EXTENSION_HINT.contains("rpi_plugin_register_v2"));
1646        assert!(CDYLIB_EXTENSION_HINT.contains("rpi_plugin_register_v3"));
1647    }
1648
1649    #[test]
1650    fn registry_read_distinguishes_missing_and_valid_files() {
1651        let temp = tempfile::tempdir().unwrap();
1652        let path = temp.path().join(NATIVE_PACKAGES_FILE);
1653
1654        assert_eq!(
1655            read_native_package_registry_at(&path).unwrap(),
1656            NativePackageRegistry::Missing
1657        );
1658
1659        std::fs::write(
1660            &path,
1661            br#"[{"name":"demo","version":"1.2.3","source":null}]"#,
1662        )
1663        .unwrap();
1664        assert_eq!(
1665            read_native_package_registry_at(&path).unwrap(),
1666            NativePackageRegistry::Valid(vec![InstalledNativePackage {
1667                name: "demo".to_string(),
1668                version: "1.2.3".to_string(),
1669                source: None,
1670                prebuilt: None,
1671                artifacts: Vec::new(),
1672            }])
1673        );
1674    }
1675
1676    #[test]
1677    fn registry_read_rejects_corrupt_json() {
1678        let temp = tempfile::tempdir().unwrap();
1679        let path = temp.path().join(NATIVE_PACKAGES_FILE);
1680        std::fs::write(&path, b"[{").unwrap();
1681
1682        let error = read_native_package_registry_at(&path).unwrap_err();
1683        assert!(error.contains("invalid JSON or schema"));
1684    }
1685
1686    #[test]
1687    fn registry_read_rejects_wrong_schema() {
1688        let temp = tempfile::tempdir().unwrap();
1689        let path = temp.path().join(NATIVE_PACKAGES_FILE);
1690        std::fs::write(&path, br#"{"name":"demo","version":"1.2.3"}"#).unwrap();
1691
1692        let error = read_native_package_registry_at(&path).unwrap_err();
1693        assert!(error.contains("invalid JSON or schema"));
1694    }
1695
1696    #[test]
1697    fn record_does_not_replace_a_corrupt_registry() {
1698        let temp = tempfile::tempdir().unwrap();
1699        let path = temp.path().join(NATIVE_PACKAGES_FILE);
1700        let original = b"[{broken metadata";
1701        std::fs::write(&path, original).unwrap();
1702
1703        assert!(record_native_package_at(&path, &native_record("demo")).is_err());
1704        assert_eq!(std::fs::read(&path).unwrap(), original);
1705    }
1706
1707    #[test]
1708    fn native_install_transaction_replaces_artifacts_and_removes_stale_ones() {
1709        let temp = tempfile::tempdir().unwrap();
1710        let destination = temp.path().join("extensions");
1711        let metadata_path = temp.path().join(NATIVE_PACKAGES_FILE);
1712        let build = temp.path().join("build");
1713        std::fs::create_dir_all(&destination).unwrap();
1714        std::fs::create_dir_all(&build).unwrap();
1715        std::fs::write(destination.join("old.dll"), b"old-only").unwrap();
1716        std::fs::write(destination.join("same.dll"), b"old-shared").unwrap();
1717        let old = InstalledNativePackage {
1718            name: "demo".into(),
1719            version: "1.0.0".into(),
1720            source: None,
1721            prebuilt: None,
1722            artifacts: vec!["old.dll".into(), "same.dll".into()],
1723        };
1724        write_native_packages_at(&metadata_path, &[old]).unwrap();
1725        std::fs::write(build.join("new.dll"), b"new-only").unwrap();
1726        std::fs::write(build.join("same.dll"), b"new-shared").unwrap();
1727
1728        let installed = install_native_package_at(
1729            &destination,
1730            &metadata_path,
1731            InstalledNativePackage {
1732                name: "demo".into(),
1733                version: "2.0.0".into(),
1734                source: None,
1735                prebuilt: None,
1736                artifacts: Vec::new(),
1737            },
1738            &[build.join("new.dll"), build.join("same.dll")],
1739            false,
1740        )
1741        .unwrap();
1742
1743        assert_eq!(
1744            installed,
1745            vec![destination.join("new.dll"), destination.join("same.dll")]
1746        );
1747        assert!(!destination.join("old.dll").exists());
1748        assert_eq!(
1749            std::fs::read(destination.join("new.dll")).unwrap(),
1750            b"new-only"
1751        );
1752        assert_eq!(
1753            std::fs::read(destination.join("same.dll")).unwrap(),
1754            b"new-shared"
1755        );
1756        let records = read_native_package_registry_at(&metadata_path)
1757            .unwrap()
1758            .into_records();
1759        assert_eq!(records.len(), 1);
1760        assert_eq!(records[0].version, "2.0.0");
1761        assert_eq!(records[0].artifacts, vec!["new.dll", "same.dll"]);
1762        assert_no_native_transaction_files(temp.path());
1763        assert_no_native_transaction_files(&destination);
1764    }
1765
1766    #[test]
1767    fn native_install_transaction_rolls_back_artifacts_and_registry() {
1768        let temp = tempfile::tempdir().unwrap();
1769        let destination = temp.path().join("extensions");
1770        let metadata_path = temp.path().join(NATIVE_PACKAGES_FILE);
1771        let build = temp.path().join("build");
1772        std::fs::create_dir_all(&destination).unwrap();
1773        std::fs::create_dir_all(&build).unwrap();
1774        std::fs::write(destination.join("old.dll"), b"old-only").unwrap();
1775        std::fs::write(destination.join("same.dll"), b"old-shared").unwrap();
1776        let old = InstalledNativePackage {
1777            name: "demo".into(),
1778            version: "1.0.0".into(),
1779            source: None,
1780            prebuilt: None,
1781            artifacts: vec!["old.dll".into(), "same.dll".into()],
1782        };
1783        write_native_packages_at(&metadata_path, &[old]).unwrap();
1784        let original_registry = std::fs::read(&metadata_path).unwrap();
1785        std::fs::write(build.join("new.dll"), b"new-only").unwrap();
1786        std::fs::write(build.join("same.dll"), b"new-shared").unwrap();
1787
1788        let error = install_native_package_at_with_hook(
1789            &destination,
1790            &metadata_path,
1791            InstalledNativePackage {
1792                name: "demo".into(),
1793                version: "2.0.0".into(),
1794                source: None,
1795                prebuilt: None,
1796                artifacts: Vec::new(),
1797            },
1798            &[build.join("new.dll"), build.join("same.dll")],
1799            false,
1800            |_, target| {
1801                if target == metadata_path {
1802                    Err("injected registry activation failure".into())
1803                } else {
1804                    Ok(())
1805                }
1806            },
1807        )
1808        .unwrap_err();
1809
1810        assert!(error.contains("injected registry activation failure"));
1811        assert_eq!(std::fs::read(&metadata_path).unwrap(), original_registry);
1812        assert_eq!(
1813            std::fs::read(destination.join("old.dll")).unwrap(),
1814            b"old-only"
1815        );
1816        assert_eq!(
1817            std::fs::read(destination.join("same.dll")).unwrap(),
1818            b"old-shared"
1819        );
1820        assert!(!destination.join("new.dll").exists());
1821        assert_no_native_transaction_files(temp.path());
1822        assert_no_native_transaction_files(&destination);
1823    }
1824
1825    fn assert_no_native_transaction_files(directory: &Path) {
1826        for entry in std::fs::read_dir(directory).unwrap() {
1827            let name = entry.unwrap().file_name().to_string_lossy().into_owned();
1828            assert!(!name.contains(".rpi-stage-"), "left staged file: {name}");
1829            assert!(!name.contains(".rpi-backup-"), "left backup file: {name}");
1830        }
1831    }
1832
1833    #[test]
1834    fn best_effort_registry_read_omits_corrupt_metadata() {
1835        let agent = TempAgent::new();
1836        std::fs::write(agent.metadata_path(), b"[{broken metadata").unwrap();
1837
1838        assert!(installed_native_packages().is_empty());
1839    }
1840
1841    #[test]
1842    fn install_fails_closed_before_work_when_registry_is_corrupt() {
1843        let agent = TempAgent::new();
1844        let path = agent.metadata_path();
1845        let original = b"[{broken metadata";
1846        std::fs::write(&path, original).unwrap();
1847
1848        assert_eq!(run(&args(&["demo"])), 1);
1849        assert_eq!(std::fs::read(&path).unwrap(), original);
1850        assert!(!agent.temp.path().join("extensions").exists());
1851    }
1852
1853    #[test]
1854    fn uninstall_fails_closed_without_removing_artifacts() {
1855        let agent = TempAgent::new();
1856        let path = agent.metadata_path();
1857        let original = b"[{broken metadata";
1858        std::fs::write(&path, original).unwrap();
1859        let extension_dir = agent.temp.path().join("extensions");
1860        std::fs::create_dir_all(&extension_dir).unwrap();
1861        let artifact = extension_dir.join("demo.dll");
1862        std::fs::write(&artifact, b"extension").unwrap();
1863
1864        assert_eq!(uninstall(&args(&["demo"])), 1);
1865        assert_eq!(std::fs::read(&path).unwrap(), original);
1866        assert_eq!(std::fs::read(artifact).unwrap(), b"extension");
1867    }
1868
1869    #[test]
1870    fn uninstall_commits_artifact_and_registry_changes_together() {
1871        let agent = TempAgent::new();
1872        let extension_dir = agent.temp.path().join("extensions");
1873        std::fs::create_dir_all(&extension_dir).unwrap();
1874        std::fs::write(extension_dir.join("demo.dll"), b"demo").unwrap();
1875        std::fs::write(extension_dir.join("other.dll"), b"other").unwrap();
1876        write_native_packages_at(
1877            &agent.metadata_path(),
1878            &[native_record("demo"), native_record("other")],
1879        )
1880        .unwrap();
1881
1882        assert_eq!(uninstall(&args(&["demo"])), 0);
1883        assert!(!extension_dir.join("demo.dll").exists());
1884        assert_eq!(
1885            std::fs::read(extension_dir.join("other.dll")).unwrap(),
1886            b"other"
1887        );
1888        assert_eq!(
1889            read_native_package_registry_at(&agent.metadata_path())
1890                .unwrap()
1891                .into_records(),
1892            vec![native_record("other")]
1893        );
1894        assert_no_native_transaction_files(agent.temp.path());
1895        assert_no_native_transaction_files(&extension_dir);
1896    }
1897
1898    #[test]
1899    fn uninstall_uses_recorded_artifacts_without_name_fallback() {
1900        let agent = TempAgent::new();
1901        let extension_dir = agent.temp.path().join("extensions");
1902        std::fs::create_dir_all(&extension_dir).unwrap();
1903        std::fs::write(extension_dir.join("recorded.dll"), b"managed").unwrap();
1904        std::fs::write(extension_dir.join("demo.dll"), b"unmanaged").unwrap();
1905        write_native_packages_at(
1906            &agent.metadata_path(),
1907            &[InstalledNativePackage {
1908                name: "demo".into(),
1909                version: "1.2.3".into(),
1910                source: None,
1911                prebuilt: None,
1912                artifacts: vec!["recorded.dll".into()],
1913            }],
1914        )
1915        .unwrap();
1916
1917        assert_eq!(uninstall(&args(&["demo"])), 0);
1918        assert!(!extension_dir.join("recorded.dll").exists());
1919        assert_eq!(
1920            std::fs::read(extension_dir.join("demo.dll")).unwrap(),
1921            b"unmanaged"
1922        );
1923        assert!(!agent.metadata_path().exists());
1924        assert_no_native_transaction_files(agent.temp.path());
1925        assert_no_native_transaction_files(&extension_dir);
1926    }
1927}