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