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