1use serde::{Deserialize, Serialize};
3use serde_json::{json, Value};
4use sha2::{Digest, Sha256};
5use std::{
6 collections::BTreeSet,
7 fs::{self, File, OpenOptions},
8 io::{Read, Write},
9 path::{Component, Path, PathBuf},
10 sync::Arc,
11};
12
13const RECEIPTS: &str = "resource-receipts.json";
14const JOURNAL: &str = "resource-transaction.json";
15const LEASE: &str = ".resource-lease";
16const MAX_META: usize = 256 * 1024;
17const MAX_FILES: usize = 2048;
18const MAX_FILE: u64 = 64 * 1024 * 1024;
19const MAX_BYTES: u64 = 256 * 1024 * 1024;
20pub(crate) fn capabilities() -> Value {
22 json!({"version":1,"command":"resource","kinds":["package","skill","extension"],
23 "scopes":["project","global"],"explicitScopeRequired":true,
24 "operations":["list","inspect","install","update","reinstall","remove","recover"],
25 "localSnapshotsOnly":true,"build":false,"network":false,"hotApply":false,
26 "bounds":{"maxMetadataBytes":MAX_META,"maxResultBytes":MAX_META,"maxResourcesPerScope":128,
27 "maxInventoryEntries":MAX_FILES,"maxDepth":32,"maxFileBytes":MAX_FILE,"maxTotalBytes":MAX_BYTES}})
28}
29
30type Result<T> = std::result::Result<T, String>;
31fn err(e: impl std::fmt::Display) -> String {
32 e.to_string()
33}
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum Scope {
38 #[default]
39 Project,
40 Global,
41}
42impl Scope {
43 pub fn is_project(&self) -> bool {
44 *self == Self::Project
45 }
46 pub fn root(self, cwd: &Path) -> Result<PathBuf> {
47 let logical = match self {
48 Self::Project => cwd.join(".rpi"),
49 Self::Global => crate::config::agent_dir().map_err(err)?,
50 };
51 resolve_scope_root(&logical)
52 }
53}
54
55fn resolve_scope_root(root: &Path) -> Result<PathBuf> {
62 if !root.is_absolute() {
63 return Err("resource root must be absolute".into());
64 }
65 if root.components().any(|c| matches!(c, Component::ParentDir)) {
66 return Err("resource path traversal".into());
67 }
68 let Some(name) = root.file_name() else {
69 checked_path(root)?;
71 return Ok(root.to_path_buf());
72 };
73 #[cfg(windows)]
74 checked_scope_tail_component(name)?;
75 let mut parent = root.parent().ok_or("resource root has no parent")?;
76 let mut missing = Vec::new();
77 loop {
78 match fs::symlink_metadata(parent) {
79 Ok(_) => break,
80 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
81 missing.push(
82 parent
83 .file_name()
84 .ok_or("resource root has no existing parent")?
85 .to_os_string(),
86 );
87 parent = parent
88 .parent()
89 .ok_or("resource root has no existing parent")?;
90 }
91 Err(e) => {
92 return Err(format!(
93 "checking resource root parent {}: {e}",
94 parent.display()
95 ))
96 }
97 }
98 }
99 let mut resolved = fs::canonicalize(parent).map_err(err)?;
100 for component in missing.into_iter().rev() {
101 #[cfg(windows)]
102 checked_scope_tail_component(&component)?;
103 resolved.push(component);
104 }
105 resolved.push(name);
106 checked_path(&resolved)?;
107 Ok(resolved)
108}
109
110#[cfg(windows)]
111fn checked_scope_tail_component(component: &std::ffi::OsStr) -> Result<()> {
112 let name = component
113 .to_str()
114 .ok_or("non-UTF-8 resource scope component")?;
115 if name.ends_with(['.', ' ']) || name.contains(':') {
119 return Err("ambiguous Windows resource scope component".into());
120 }
121 reserved_identity(name)
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "lowercase")]
126enum Kind {
127 Package,
128 Skill,
129 Extension,
130}
131impl Kind {
132 fn parse(s: &str) -> Result<Self> {
133 match s {
134 "package" => Ok(Self::Package),
135 "skill" => Ok(Self::Skill),
136 "extension" => Ok(Self::Extension),
137 _ => Err("kind must be package, skill or extension".into()),
138 }
139 }
140 fn dir(self) -> &'static str {
141 match self {
142 Self::Package => "packages",
143 Self::Skill => "skills",
144 Self::Extension => "extensions",
145 }
146 }
147 fn target(self, name: &str) -> PathBuf {
148 Path::new(self.dir()).join(if self == Self::Extension {
149 format!("{name}.{}", std::env::consts::DLL_EXTENSION)
150 } else {
151 name.into()
152 })
153 }
154}
155fn identifier(s: &str) -> Result<()> {
156 if s.is_empty()
157 || s.len() > 96
158 || !s.as_bytes()[0].is_ascii_alphanumeric()
159 || !s
160 .bytes()
161 .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.'))
162 || s.ends_with('.')
163 {
164 return Err("invalid resource identity (portable ASCII name required)".into());
165 }
166 reserved_identity(s)
167}
168
169fn legacy_package_identity(s: &str) -> Result<()> {
172 if !crate::packages::valid_name(s) {
173 return Err("invalid native package identity".into());
174 }
175 relative(Path::new(s))?;
176 reserved_identity(s)
177}
178
179fn reserved_identity(s: &str) -> Result<()> {
180 let stem = s.split('.').next().unwrap().to_ascii_uppercase();
181 if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
182 || (stem.len() == 4
183 && (stem.starts_with("COM") || stem.starts_with("LPT"))
184 && stem.as_bytes()[3].is_ascii_digit())
185 {
186 return Err("reserved resource identity".into());
187 }
188 Ok(())
189}
190fn relative(path: &Path) -> Result<()> {
191 if path.as_os_str().is_empty()
192 || path
193 .components()
194 .any(|c| !matches!(c, Component::Normal(_)))
195 {
196 return Err("unsafe relative resource path".into());
197 }
198 for part in path {
199 let s = part.to_str().ok_or("non-UTF-8 resource path")?;
200 if s.len() > 128 || s.contains([':', '\\']) || s.ends_with(['.', ' ']) {
201 return Err("unsafe resource filename".into());
202 }
203 }
204 Ok(())
205}
206fn is_link(meta: &fs::Metadata) -> bool {
207 #[cfg(windows)]
208 {
209 use std::os::windows::fs::MetadataExt;
210 meta.file_type().is_symlink() || meta.file_attributes() & 0x400 != 0
211 }
212 #[cfg(not(windows))]
213 {
214 meta.file_type().is_symlink()
215 }
216}
217pub(crate) fn checked_path(path: &Path) -> Result<()> {
219 if !path.is_absolute() {
220 return Err("resource path must be absolute".into());
221 }
222 let mut current = PathBuf::new();
223 for c in path.components() {
224 if matches!(c, Component::ParentDir) {
225 return Err("resource path traversal".into());
226 }
227 current.push(c);
228 if matches!(c, Component::Prefix(_)) || !current.is_absolute() {
231 continue;
232 }
233 match fs::symlink_metadata(¤t) {
234 Ok(m) if is_link(&m) => {
235 return Err(format!(
236 "links/reparse points are not owned resource paths: {}",
237 current.display()
238 ))
239 }
240 Ok(_) => {}
241 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
242 Err(e) => {
243 return Err(format!(
244 "checking resource ancestor {}: {e}",
245 current.display()
246 ))
247 }
248 }
249 }
250 Ok(())
251}
252fn read_bytes(path: &Path, limit: usize) -> Result<Option<Vec<u8>>> {
253 checked_path(path)?;
254 let mut file = match File::open(path) {
255 Ok(f) => f,
256 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
257 Err(e) => return Err(err(e)),
258 };
259 if !file.metadata().map_err(err)?.is_file() {
260 return Err("resource metadata is not a regular file".into());
261 }
262 let mut bytes = Vec::new();
263 Read::by_ref(&mut file)
264 .take(limit as u64 + 1)
265 .read_to_end(&mut bytes)
266 .map_err(err)?;
267 if bytes.len() > limit {
268 return Err(format!("resource metadata exceeds {limit} bytes"));
269 }
270 Ok(Some(bytes))
271}
272fn read_json<T: for<'a> Deserialize<'a> + Default>(path: &Path) -> Result<T> {
273 match read_bytes(path, MAX_META)? {
274 None => Ok(T::default()),
275 Some(b) => {
276 serde_json::from_slice(&b).map_err(|e| format!("invalid {}: {e}", path.display()))
277 }
278 }
279}
280fn json_bytes(value: &impl Serialize) -> Result<Vec<u8>> {
281 let b = serde_json::to_vec_pretty(value).map_err(err)?;
282 if b.len() > MAX_META {
283 Err("resource metadata/output exceeds 256 KiB".into())
284 } else {
285 Ok(b)
286 }
287}
288
289#[derive(Debug)]
291pub struct Lease {
292 _file: File,
293}
294#[derive(Debug)]
295enum LeaseError {
296 Contended,
297 Other(String),
298}
299impl From<String> for LeaseError {
300 fn from(error: String) -> Self {
301 Self::Other(error)
302 }
303}
304impl From<LeaseError> for String {
305 fn from(error: LeaseError) -> Self {
306 match error {
307 LeaseError::Contended => {
308 "resource scope is in use (host or mutation lease); retry after owners exit".into()
309 }
310 LeaseError::Other(e) => format!("resource lease error: {e}"),
311 }
312 }
313}
314fn lock_error(error: std::io::Error) -> LeaseError {
315 let busy = fs2::lock_contended_error();
316 if error.raw_os_error().is_some() && error.raw_os_error() == busy.raw_os_error() {
317 LeaseError::Contended
318 } else {
319 LeaseError::Other(error.to_string())
320 }
321}
322impl Lease {
323 fn acquire(
324 root: &Path,
325 exclusive: bool,
326 create: bool,
327 ) -> std::result::Result<Option<Self>, LeaseError> {
328 checked_path(root)?;
329 if create {
330 fs::create_dir_all(root).map_err(err)?;
331 }
332 let path = root.join(LEASE);
333 checked_path(&path)?;
334 let file = match OpenOptions::new()
335 .read(true)
336 .write(true)
337 .create(create)
338 .open(path)
339 {
340 Ok(f) => f,
341 Err(e) if !create && e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
342 Err(e) => return Err(LeaseError::Other(err(e))),
343 };
344 let result = if exclusive {
345 fs2::FileExt::try_lock_exclusive(&file)
346 } else {
347 fs2::FileExt::try_lock_shared(&file)
348 };
349 result.map_err(lock_error)?;
350 Ok(Some(Self { _file: file }))
351 }
352}
353impl Drop for Lease {
354 fn drop(&mut self) {
355 let _ = fs2::FileExt::unlock(&self._file);
356 }
357}
358pub(crate) fn mutation_lease(root: &Path) -> Result<Lease> {
359 let lease = Lease::acquire(root, true, true)?.ok_or("missing resource lease")?;
360 ensure_complete(root)?;
361 Ok(lease)
362}
363
364pub(crate) fn shared_lease(root: &Path) -> Result<Lease> {
369 let lease = Lease::acquire(root, false, true)?.ok_or("missing resource lease")?;
370 ensure_complete(root)?;
371 Ok(lease)
372}
373
374fn ensure_complete(root: &Path) -> Result<()> {
375 if read_bytes(&root.join(JOURNAL), MAX_META)?.is_some() {
376 return Err(
377 "incomplete resource transaction; run rpi resource recover with this scope".into(),
378 );
379 }
380 Ok(())
381}
382pub(crate) fn host_leases(cwd: &Path) -> Result<Arc<Vec<Lease>>> {
383 let mut roots = vec![Scope::Project.root(cwd)?, Scope::Global.root(cwd)?];
384 roots.sort();
385 roots.dedup();
386 let mut leases = Vec::new();
387 for root in roots {
388 leases.push(shared_lease(&root)?);
389 }
390 Ok(Arc::new(leases))
391}
392fn observation_lease(root: &Path) -> Result<(Option<Lease>, bool)> {
393 match Lease::acquire(root, true, false) {
396 Ok(lease) => Ok((lease, false)),
397 Err(LeaseError::Contended) => Ok((Lease::acquire(root, false, false)?, true)),
398 Err(e) => Err(e.into()),
399 }
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
403#[serde(deny_unknown_fields)]
404struct ResourceFile {
405 path: PathBuf,
406 bytes: u64,
407 sha256: String,
408}
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
410#[serde(deny_unknown_fields)]
411struct Inventory {
412 files: Vec<ResourceFile>,
413 directories: Vec<PathBuf>,
414 sha256: String,
415}
416#[derive(Default)]
417struct InventoryBudget {
418 entries: usize,
419 bytes: u64,
420}
421fn inventory(path: &Path) -> Result<Inventory> {
422 inventory_bounded(path, &mut InventoryBudget::default())
423}
424fn inventory_bounded(path: &Path, budget: &mut InventoryBudget) -> Result<Inventory> {
425 checked_path(path)?;
426 let mut files = Vec::new();
427 let mut directories = Vec::new();
428 fn walk(
429 root: &Path,
430 path: &Path,
431 files: &mut Vec<ResourceFile>,
432 dirs: &mut Vec<PathBuf>,
433 budget: &mut InventoryBudget,
434 depth: usize,
435 ) -> Result<()> {
436 if depth > 32 {
437 return Err("resource recursion depth exceeds 32".into());
438 }
439 if budget.entries >= MAX_FILES {
440 return Err("resource inventory exceeds 2048 entries".into());
441 }
442 budget.entries += 1;
443 let meta = fs::symlink_metadata(path).map_err(err)?;
444 if is_link(&meta) {
445 return Err(format!(
446 "resource link/reparse point rejected: {}",
447 path.display()
448 ));
449 }
450 let rel = path.strip_prefix(root).map_err(err)?;
451 if !rel.as_os_str().is_empty() {
452 relative(rel)?;
453 }
454 if meta.is_dir() {
455 if !rel.as_os_str().is_empty() {
456 dirs.push(rel.to_path_buf());
457 }
458 let remaining = MAX_FILES - budget.entries;
459 let mut entries = Vec::new();
460 for entry in fs::read_dir(path).map_err(err)?.take(remaining + 1) {
461 if entries.len() == remaining {
462 return Err("resource directory exceeds remaining entry budget".into());
463 }
464 entries.push(entry.map_err(err)?);
465 }
466 entries.sort_by_key(|e| e.file_name());
467 for e in entries {
468 walk(root, &e.path(), files, dirs, budget, depth + 1)?;
469 }
470 } else if meta.is_file() {
471 if meta.len() > MAX_FILE || budget.bytes + meta.len() > MAX_BYTES {
472 return Err("resource files exceed byte limit (64 MiB/file, 256 MiB total)".into());
473 }
474 let mut input = File::open(path).map_err(err)?;
475 let mut hash = Sha256::new();
476 let mut size = 0;
477 let mut buf = [0; 16384];
478 loop {
479 let n = input.read(&mut buf).map_err(err)?;
480 if n == 0 {
481 break;
482 }
483 size += n as u64;
484 budget.bytes += n as u64;
485 if size > MAX_FILE || budget.bytes > MAX_BYTES {
486 return Err("resource changed/exceeded byte limit".into());
487 }
488 hash.update(&buf[..n]);
489 }
490 if size != meta.len() {
491 return Err("resource changed while reading".into());
492 }
493 files.push(ResourceFile {
494 path: if rel.as_os_str().is_empty() {
495 PathBuf::from(".")
496 } else {
497 rel.into()
498 },
499 bytes: size,
500 sha256: format!("{:x}", hash.finalize()),
501 });
502 } else {
503 return Err("special resource file rejected".into());
504 }
505 Ok(())
506 }
507 walk(path, path, &mut files, &mut directories, budget, 0)?;
508 let sha256 = format!(
509 "{:x}",
510 Sha256::digest(serde_json::to_vec(&(&files, &directories)).map_err(err)?)
511 );
512 Ok(Inventory {
513 files,
514 directories,
515 sha256,
516 })
517}
518#[derive(Debug, Clone, Serialize, Deserialize)]
519#[serde(deny_unknown_fields)]
520struct Receipt {
521 kind: Kind,
522 name: String,
523 source: PathBuf,
524 version: Option<String>,
525 target: PathBuf,
526 inventory: Inventory,
527}
528#[derive(Default, Serialize, Deserialize)]
529#[serde(deny_unknown_fields)]
530struct Receipts {
531 #[serde(default)]
532 resources: Vec<Receipt>,
533}
534fn receipts(root: &Path) -> Result<Receipts> {
535 let r: Receipts = read_json(&root.join(RECEIPTS))?;
536 let mut keys = BTreeSet::new();
537 if r.resources.len() > 128 {
538 return Err("resource receipts exceed 128 resources per scope".into());
539 }
540 for item in &r.resources {
541 identifier(&item.name)?;
542 if item.version.as_ref().is_some_and(|v| {
543 v.trim().is_empty() || v.len() > 128 || v.chars().any(char::is_control)
544 }) {
545 return Err("invalid resource receipt version".into());
546 }
547 if item.target != item.kind.target(&item.name)
548 || !item.source.is_absolute()
549 || !keys.insert(item.target.to_string_lossy().to_ascii_lowercase())
550 {
551 return Err("unsafe/duplicate resource receipt".into());
552 }
553 checked_path(&root.join(&item.target))?;
554 if item.inventory.files.len() + item.inventory.directories.len() > MAX_FILES {
555 return Err("oversized receipt inventory".into());
556 }
557 for file in &item.inventory.files {
558 if file.path != Path::new(".") {
559 relative(&file.path)?;
560 }
561 }
562 for dir in &item.inventory.directories {
563 relative(dir)?;
564 }
565 }
566 Ok(r)
567}
568#[derive(Default, Serialize, Deserialize)]
569#[serde(deny_unknown_fields)]
570struct Packages {
571 #[serde(default)]
572 packages: Vec<crate::packages::ProjectPackage>,
573}
574fn packages(root: &Path) -> Result<Packages> {
575 let p: Packages = read_json(&root.join("packages.json"))?;
576 let mut names = BTreeSet::new();
577 for item in &p.packages {
578 legacy_package_identity(&item.name)?;
579 if !names.insert(item.name.to_ascii_lowercase()) {
580 return Err("duplicate native package identity".into());
581 }
582 let mut artifacts = BTreeSet::new();
583 for a in &item.artifacts {
584 relative(Path::new(a))?;
585 reserved_identity(a)?;
586 if Path::new(a).components().count() != 1 || !artifacts.insert(a.to_ascii_lowercase()) {
587 return Err("unsafe/duplicate native artifact".into());
588 }
589 }
590 }
591 Ok(p)
592}
593pub(crate) fn legacy_unowned(root: &Path, kind: &str, name: &str) -> Result<()> {
594 let kind = Kind::parse(kind)?;
595 if receipts(root)?
596 .resources
597 .iter()
598 .any(|r| r.kind == kind && r.name.eq_ignore_ascii_case(name))
599 {
600 return Err("resource is managed by rpi resource; use its update/remove commands".into());
601 }
602 if kind == Kind::Package {
603 legacy_package_identity(name)?;
604 let package = root.join(kind.target(name));
605 legacy_unowned_targets(root, &[package.clone(), package.join("extensions")])?;
606 }
607 Ok(())
608}
609fn path_identity(path: &Path) -> Result<PathBuf> {
610 let path = if path.is_absolute() {
611 path.to_path_buf()
612 } else {
613 std::env::current_dir().map_err(err)?.join(path)
614 };
615 checked_path(&path)?;
616 let mut ancestor = path.as_path();
619 let mut tail = Vec::new();
620 while !ancestor.try_exists().map_err(err)? {
621 tail.push(
622 ancestor
623 .file_name()
624 .ok_or("no path ancestor")?
625 .to_os_string(),
626 );
627 ancestor = ancestor.parent().ok_or("no path ancestor")?;
628 }
629 let mut identity = fs::canonicalize(ancestor).map_err(err)?;
630 for component in tail.into_iter().rev() {
631 identity.push(component);
632 }
633 if cfg!(windows) {
634 Ok(PathBuf::from(identity.to_string_lossy().to_lowercase()))
635 } else {
636 Ok(identity)
637 }
638}
639pub(crate) fn resource_owned_names(root: &Path, kind: &str) -> Result<BTreeSet<String>> {
640 let kind = Kind::parse(kind)?;
641 Ok(receipts(root)?
642 .resources
643 .into_iter()
644 .filter(|r| r.kind == kind)
645 .map(|r| r.name)
646 .collect())
647}
648pub(crate) fn legacy_unowned_targets(root: &Path, targets: &[PathBuf]) -> Result<()> {
649 let targets = targets
650 .iter()
651 .map(|t| path_identity(t))
652 .collect::<Result<Vec<_>>>()?;
653 for r in receipts(root)?.resources {
654 let owned = path_identity(&root.join(&r.target))?;
655 if targets.iter().any(|t| t == &owned || t.starts_with(&owned)) {
656 return Err("target owned by rpi resource; refusing legacy replacement/removal".into());
657 }
658 }
659 Ok(())
660}
661
662#[derive(Serialize, Deserialize)]
663#[serde(deny_unknown_fields)]
664struct Change {
665 target: PathBuf,
666 old: Option<Inventory>,
667 new: Option<Inventory>,
668}
669#[derive(Serialize, Deserialize)]
670#[serde(deny_unknown_fields)]
671struct Journal {
672 directory: String,
673 changes: Vec<Change>,
674}
675fn transaction_target(p: &Path) -> Result<()> {
676 relative(p)?;
677 if p == Path::new(RECEIPTS) || p == Path::new("packages.json") {
678 return Ok(());
679 }
680 let parts = p
681 .iter()
682 .map(|p| p.to_str().ok_or("non-UTF8 target"))
683 .collect::<std::result::Result<Vec<_>, _>>()?;
684 if parts.len() != 2 || !matches!(parts[0], "packages" | "skills" | "extensions") {
685 return Err("unsafe transaction target".into());
686 }
687 identifier(parts[1])
688}
689fn remove_path(path: &Path) -> Result<()> {
690 checked_path(path)?;
691 let m = fs::symlink_metadata(path).map_err(err)?;
692 if m.is_dir() {
693 fs::remove_dir_all(path).map_err(err)
694 } else if m.is_file() {
695 fs::remove_file(path).map_err(err)
696 } else {
697 Err("unsafe removal".into())
698 }
699}
700fn rollback(root: &Path, j: &Journal) -> Result<()> {
701 let dir = root.join(&j.directory);
702 checked_path(&dir)?;
703 for (i, c) in j.changes.iter().enumerate() {
705 transaction_target(&c.target)?;
706 let target = root.join(&c.target);
707 let backup = dir.join(format!("old-{i}"));
708 if backup.try_exists().map_err(err)? {
709 if Some(inventory(&backup)?) != c.old {
710 return Err("transaction backup changed; manual recovery required".into());
711 }
712 if target.try_exists().map_err(err)? && Some(inventory(&target)?) != c.new {
713 return Err("transaction target changed; refusing recovery deletion".into());
714 }
715 } else if target.try_exists().map_err(err)? {
716 let observed = inventory(&target)?;
717 if c.old.as_ref() != Some(&observed) && c.new.as_ref() != Some(&observed) {
718 return Err("transaction target differs from old/new inventory".into());
719 }
720 if c.old.is_some() && c.old.as_ref() != Some(&observed) {
721 return Err("missing rollback backup; manual recovery required".into());
722 }
723 } else if c.old.is_some() {
724 return Err("missing original/backup; manual recovery required".into());
725 }
726 }
727 for (i, c) in j.changes.iter().enumerate().rev() {
728 let target = root.join(&c.target);
729 let backup = dir.join(format!("old-{i}"));
730 if backup.try_exists().map_err(err)? {
731 if target.try_exists().map_err(err)? {
732 remove_path(&target)?;
733 }
734 fs::rename(&backup, &target).map_err(err)?;
735 } else if c.old.is_none() && target.try_exists().map_err(err)? {
736 remove_path(&target)?;
737 }
738 }
739 fs::remove_file(root.join(JOURNAL)).map_err(err)?;
741 fs::remove_dir_all(dir).map_err(err)
742}
743fn recover(root: &Path) -> Result<bool> {
744 let Some(bytes) = read_bytes(&root.join(JOURNAL), MAX_META)? else {
745 return Ok(false);
746 };
747 let j: Journal = serde_json::from_slice(&bytes).map_err(err)?;
748 let suffix = j
749 .directory
750 .strip_prefix(".resource-txn-")
751 .ok_or("invalid transaction directory")?;
752 uuid::Uuid::parse_str(suffix).map_err(err)?;
753 if j.changes.len() > 3 {
754 return Err("oversized transaction plan".into());
755 }
756 let mut seen = BTreeSet::new();
757 for c in &j.changes {
758 transaction_target(&c.target)?;
759 if !seen.insert(&c.target) {
760 return Err("duplicate transaction target".into());
761 }
762 }
763 rollback(root, &j)?;
764 Ok(true)
765}
766fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
767 let mut f = OpenOptions::new()
768 .write(true)
769 .create_new(true)
770 .open(path)
771 .map_err(err)?;
772 f.write_all(bytes).and_then(|_| f.sync_all()).map_err(err)
773}
774fn copy_snapshot(source: &Path, target: &Path, observed: &Inventory) -> Result<()> {
775 fn copy_file(source: &Path, target: &Path, expected: u64) -> Result<()> {
776 checked_path(source)?;
777 let input = File::open(source).map_err(err)?;
778 #[cfg(unix)]
779 let mode = {
780 use std::os::unix::fs::PermissionsExt;
781 input.metadata().map_err(err)?.permissions().mode() & 0o777
783 };
784 let mut output = OpenOptions::new()
785 .write(true)
786 .create_new(true)
787 .open(target)
788 .map_err(err)?;
789 let copied = std::io::copy(&mut input.take(expected + 1), &mut output).map_err(err)?;
790 if copied != expected {
791 return Err("resource changed during bounded copy".into());
792 }
793 #[cfg(unix)]
794 {
795 use std::os::unix::fs::PermissionsExt;
796 output
797 .set_permissions(fs::Permissions::from_mode(mode))
798 .map_err(err)?;
799 }
800 output.sync_all().map_err(err)
801 }
802 if source.is_file() {
803 copy_file(
804 source,
805 target,
806 observed
807 .files
808 .first()
809 .ok_or("missing file inventory")?
810 .bytes,
811 )?;
812 } else {
813 fs::create_dir(target).map_err(err)?;
814 for d in &observed.directories {
815 fs::create_dir_all(target.join(d)).map_err(err)?;
816 }
817 for f in &observed.files {
818 let to = target.join(&f.path);
819 if let Some(p) = to.parent() {
820 fs::create_dir_all(p).map_err(err)?;
821 }
822 copy_file(&source.join(&f.path), &to, f.bytes)?;
823 }
824 }
825 if inventory(target)? != *observed || inventory(source)? != *observed {
826 return Err("resource source changed during snapshot copy".into());
827 }
828 Ok(())
829}
830fn publish(
832 root: &Path,
833 changes: Vec<(PathBuf, Option<PathBuf>)>,
834 stage: tempfile::TempDir,
835 mut before: impl FnMut(usize) -> Result<()>,
836) -> Result<Vec<String>> {
837 let directory = stage
838 .path()
839 .file_name()
840 .and_then(|s| s.to_str())
841 .ok_or("invalid staging directory")?
842 .to_owned();
843 let mut j = Journal {
844 directory,
845 changes: Vec::new(),
846 };
847 for (target, new) in &changes {
848 transaction_target(target)?;
849 let path = root.join(target);
850 checked_path(&path)?;
851 j.changes.push(Change {
852 target: target.clone(),
853 old: if path.try_exists().map_err(err)? {
854 Some(inventory(&path)?)
855 } else {
856 None
857 },
858 new: new.as_ref().map(|p| inventory(p)).transpose()?,
859 });
860 }
861 let journal_stage = stage.path().join("journal");
862 write_new(&journal_stage, &json_bytes(&j)?)?;
863 fs::rename(&journal_stage, root.join(JOURNAL)).map_err(err)?;
864 let _owned = stage.keep();
866 let result = (|| {
867 for (i, (target, new)) in changes.iter().enumerate() {
868 before(i * 2)?;
869 let path = root.join(target);
870 if j.changes[i].old.is_some() {
871 fs::rename(&path, root.join(&j.directory).join(format!("old-{i}"))).map_err(err)?;
872 }
873 before(i * 2 + 1)?;
874 if let Some(new) = new {
875 fs::rename(new, &path).map_err(err)?;
876 }
877 }
878 before(changes.len() * 2)?;
879 Ok(())
880 })();
881 if let Err(error) = result {
882 return match rollback(root, &j) {
883 Ok(()) => Err(error),
884 Err(r) => Err(format!(
885 "{error}; rollback incomplete: {r}; run resource recover"
886 )),
887 };
888 }
889 if let Err(error) = fs::remove_file(root.join(JOURNAL)) {
892 return match rollback(root,&j) {
893 Ok(())=>Err(format!("commit marker removal failed: {error}; rolled back")),
894 Err(rollback)=>Err(format!("commit marker removal failed: {error}; rollback incomplete: {rollback}; run resource recover")),
895 };
896 }
897 if let Err(e) = fs::remove_dir_all(root.join(&j.directory)) {
898 return Ok(vec![format!(
899 "committed; backup cleanup pending (OS error {:?}, {:?})",
900 e.raw_os_error(),
901 e.kind()
902 )]);
903 }
904 Ok(Vec::new())
905}
906fn stage_dir(root: &Path) -> Result<tempfile::TempDir> {
907 tempfile::Builder::new()
908 .prefix(&format!(".resource-txn-{}", uuid::Uuid::new_v4().simple()))
909 .rand_bytes(0)
910 .tempdir_in(root)
911 .map_err(err)
912}
913
914#[derive(Default)]
915struct Options {
916 op: String,
917 kind: Option<Kind>,
918 name: Option<String>,
919 scope: Option<Scope>,
920 source: Option<PathBuf>,
921 version: Option<String>,
922}
923fn parse(args: &[String]) -> Result<Options> {
924 let mut o = Options {
925 op: args.first().ok_or("missing resource operation")?.clone(),
926 ..Default::default()
927 };
928 let mut pos = Vec::new();
929 let mut i = 1;
930 while i < args.len() {
931 let a = &args[i];
932 if matches!(a.as_str(), "--scope" | "--path" | "--version") {
933 i += 1;
934 let v = args.get(i).ok_or("missing option value")?;
935 if v.starts_with('-') {
936 return Err("missing option value".into());
937 }
938 match a.as_str() {
939 "--scope" => {
940 if o.scope.is_some() {
941 return Err("duplicate scope".into());
942 }
943 o.scope = Some(match v.as_str() {
944 "project" => Scope::Project,
945 "global" => Scope::Global,
946 _ => return Err("scope must be project or global".into()),
947 });
948 }
949 "--path" => {
950 if o.source.replace(v.into()).is_some() {
951 return Err("duplicate path".into());
952 }
953 }
954 _ => {
955 if v.trim().is_empty()
956 || v.len() > 128
957 || v.chars().any(char::is_control)
958 || o.version.replace(v.clone()).is_some()
959 {
960 return Err("invalid/duplicate version".into());
961 }
962 }
963 }
964 } else if a.starts_with('-') {
965 return Err(format!(
966 "unknown resource option {a}; builds require explicit native package link/install"
967 ));
968 } else {
969 pos.push(a.clone());
970 }
971 i += 1;
972 }
973 if o.scope.is_none() {
974 return Err("explicit --scope project|global is required".into());
975 }
976 if matches!(o.op.as_str(), "list" | "recover") {
977 if !pos.is_empty() || o.source.is_some() || o.version.is_some() {
978 return Err("unexpected list/recover arguments".into());
979 }
980 } else if matches!(
981 o.op.as_str(),
982 "inspect" | "install" | "update" | "reinstall" | "remove"
983 ) {
984 if pos.len() != 2 {
985 return Err("expected KIND NAME".into());
986 }
987 o.kind = Some(Kind::parse(&pos[0])?);
988 if o.op == "inspect" && o.kind == Some(Kind::Package) {
989 legacy_package_identity(&pos[1])?;
990 } else {
991 identifier(&pos[1])?;
992 }
993 o.name = Some(pos[1].clone());
994 let mutation = matches!(o.op.as_str(), "install" | "update" | "reinstall");
995 if mutation != o.source.is_some() || (!mutation && o.version.is_some()) {
996 return Err("install/update/reinstall require --path; inspect/remove reject it".into());
997 }
998 } else {
999 return Err("unknown resource operation".into());
1000 }
1001 Ok(o)
1002}
1003fn validate_source(kind: Kind, source: &Path, inv: &Inventory) -> Result<()> {
1004 match kind {
1005 Kind::Skill => {
1006 if !source.is_dir() || !source.join("SKILL.md").is_file() {
1007 return Err("standalone Skill requires a directory containing SKILL.md".into());
1008 }
1009 let text =
1010 read_bytes(&source.join("SKILL.md"), 64 * 1024)?.ok_or("missing SKILL.md")?;
1011 std::str::from_utf8(&text).map_err(err)?;
1012 }
1013 Kind::Extension => {
1014 if !source.is_file()
1015 || source.extension().and_then(|s| s.to_str())
1016 != Some(std::env::consts::DLL_EXTENSION)
1017 {
1018 return Err("extension source must be a platform native library file".into());
1019 }
1020 }
1021 Kind::Package => {
1022 if !source.is_dir() {
1023 return Err("package source must be a directory".into());
1024 }
1025 if !["extensions", "skills", "prompts", "themes"]
1026 .iter()
1027 .any(|s| source.join(s).is_dir())
1028 && inv.files.is_empty()
1029 {
1030 return Err("empty package".into());
1031 }
1032 for f in &inv.files {
1033 if f.path.starts_with("extensions")
1034 && (f.path.components().count() != 2
1035 || f.path.extension().and_then(|s| s.to_str())
1036 != Some(std::env::consts::DLL_EXTENSION))
1037 {
1038 return Err(
1039 "Package extensions must be direct platform native artifacts".into(),
1040 );
1041 }
1042 }
1043 }
1044 }
1045 Ok(())
1046}
1047fn explicit_source(path: &Path) -> Result<PathBuf> {
1048 let addressed = std::env::current_dir().map_err(err)?.join(path);
1049 let mut normalized = PathBuf::new();
1050 for component in addressed.components() {
1051 match component {
1052 Component::CurDir => {}
1053 Component::ParentDir => {
1054 checked_path(&normalized)?;
1055 if !normalized.pop() {
1056 return Err("source escapes filesystem root".into());
1057 }
1058 }
1059 other => {
1060 normalized.push(other);
1061 if !matches!(other, Component::Prefix(_)) && normalized.is_absolute() {
1064 checked_path(&normalized)?;
1065 }
1066 }
1067 }
1068 }
1069 fs::canonicalize(normalized).map_err(err)
1070}
1071fn mutate(root: &Path, o: &Options) -> Result<Value> {
1072 let _lease = mutation_lease(root)?;
1073 let mut r = receipts(root)?;
1074 let mut p = packages(root)?;
1075 let native: Vec<crate::install::InstalledNativePackage> =
1076 read_json(&root.join("native-packages.json"))?;
1077 let kind = o.kind.ok_or("missing kind")?;
1078 let name = o.name.as_deref().ok_or("missing name")?;
1079 let target = kind.target(name);
1080 let path = root.join(&target);
1081 checked_path(&path)?;
1082 let previous = r
1083 .resources
1084 .iter()
1085 .find(|r| r.kind == kind && r.name.eq_ignore_ascii_case(name))
1086 .cloned();
1087 for owned in r.resources.iter().filter(|r| r.kind == Kind::Package) {
1088 if p.packages.iter().find(|p| p.name == owned.name) != Some(&package_record(owned)) {
1089 return Err(
1090 "owned package receipt disagrees with native packages.json; refusing mutation"
1091 .into(),
1092 );
1093 }
1094 }
1095 if kind == Kind::Extension
1096 && native.iter().any(|p| {
1097 p.name.eq_ignore_ascii_case(name)
1098 || p.artifacts.iter().any(|a| {
1099 target
1100 .file_name()
1101 .is_some_and(|f| f.to_string_lossy().eq_ignore_ascii_case(a))
1102 })
1103 })
1104 {
1105 return Err(
1106 "extension identity/artifact owned by legacy native registry (even if missing)".into(),
1107 );
1108 }
1109 if let Some(old) = &previous {
1110 if old.name != name {
1111 return Err("identity case collision".into());
1112 }
1113 if inventory(&path)? != old.inventory {
1114 return Err("owned resource content changed; refusing overwrite/removal".into());
1115 }
1116 }
1117 if o.op == "remove" && previous.is_none() {
1118 if path.try_exists().map_err(err)? {
1119 return Err("unmanaged destination: refusing removal".into());
1120 }
1121 return Ok(json!({"outcome":"absent","kind":kind,"name":name}));
1122 }
1123 if previous.is_none() && path.try_exists().map_err(err)? {
1124 return Err("unmanaged destination conflict".into());
1125 }
1126 if kind == Kind::Package
1127 && previous.is_none()
1128 && p.packages.iter().any(|p| p.name.eq_ignore_ascii_case(name))
1129 {
1130 return Err("package identity owned by native package link".into());
1131 }
1132 if matches!(o.op.as_str(), "update" | "reinstall") && previous.is_none() {
1133 return Err("resource is not owned/installed; use install".into());
1134 }
1135 let source = o
1136 .source
1137 .as_ref()
1138 .map(|path| explicit_source(path))
1139 .transpose()?;
1140 if let Some(source) = &source {
1141 let source_identity = path_identity(source)?;
1142 let root_identity = path_identity(root)?;
1143 if source_identity.starts_with(&root_identity)
1144 || root_identity.starts_with(&source_identity)
1145 {
1146 return Err("source/destination resource trees overlap".into());
1147 }
1148 }
1149 let stage = stage_dir(root)?;
1150 let mut next = None;
1151 if let Some(source) = source {
1152 let inv = inventory(&source)?;
1154 validate_source(kind, &source, &inv)?;
1155 let version = o
1156 .version
1157 .clone()
1158 .or_else(|| previous.as_ref().and_then(|r| r.version.clone()));
1159 let receipt = Receipt {
1160 kind,
1161 name: name.into(),
1162 source,
1163 version,
1164 target: target.clone(),
1165 inventory: inv,
1166 };
1167 if o.op == "install" {
1168 if let Some(old) = &previous {
1169 if old.source == receipt.source
1170 && old.version == receipt.version
1171 && old.inventory == receipt.inventory
1172 {
1173 return Ok(
1174 json!({"outcome":"unchanged","resource":observe(root,o.scope.unwrap(),old,false)?}),
1175 );
1176 }
1177 return Err("already installed; use update or reinstall with --path".into());
1178 }
1179 }
1180 let staged = stage.path().join("snapshot");
1181 copy_snapshot(&receipt.source, &staged, &receipt.inventory)?;
1182 next = Some((receipt, staged));
1183 }
1184 r.resources.retain(|r| !(r.kind == kind && r.name == name));
1185 if kind == Kind::Package {
1186 p.packages.retain(|p| p.name != name);
1187 if let Some((new, _)) = &next {
1188 p.packages.push(package_record(new));
1189 }
1190 }
1191 if let Some((receipt, _)) = &next {
1192 r.resources.push(receipt.clone());
1193 }
1194 if r.resources.len() > 128 {
1195 return Err("resource receipts exceed 128 resources per scope".into());
1196 }
1197 r.resources.sort_by_key(|r| r.target.clone());
1198 p.packages.sort_by(|a, b| a.name.cmp(&b.name));
1199 let receipt_stage = stage.path().join("receipts");
1200 write_new(&receipt_stage, &json_bytes(&r)?)?;
1201 let mut changes = vec![
1202 (target, next.as_ref().map(|(_, p)| p.clone())),
1203 (PathBuf::from(RECEIPTS), Some(receipt_stage)),
1204 ];
1205 if kind == Kind::Package {
1206 let staged = stage.path().join("packages");
1207 write_new(&staged, &json_bytes(&p)?)?;
1208 changes.push((PathBuf::from("packages.json"), Some(staged)));
1209 }
1210 let mut data = json!({"outcome":if o.op=="remove"{"removed"}else if previous.is_some(){"replaced"}else{"installed"},"kind":kind,"name":name,"diagnostics":[],"resource":next.as_ref().map(|(r,_)|observe_inventory(root,o.scope.unwrap(),r,false,Ok(r.inventory.clone()))).transpose()?});
1211 if json_bytes(&success(o, data.clone()))
1214 .map_err(|error| format!("resource success result rejected before publication: {error}"))?
1215 .len()
1216 > MAX_META - 4096
1217 {
1218 return Err("resource success result exceeds bounded publication budget".into());
1219 }
1220 let parent = path.parent().ok_or("no resource parent")?;
1221 checked_path(parent)?;
1222 fs::create_dir_all(parent).map_err(err)?;
1223 let diagnostics = publish(root, changes, stage, |_| Ok(()))?;
1224 data["diagnostics"] = json!(diagnostics);
1225 Ok(data)
1226}
1227fn package_record(r: &Receipt) -> crate::packages::ProjectPackage {
1228 crate::packages::ProjectPackage {
1229 name: r.name.clone(),
1230 version: r.version.clone().unwrap_or_default(),
1231 source: r.source.to_string_lossy().into_owned(),
1232 cargo_package: String::new(),
1233 artifacts: r
1234 .inventory
1235 .files
1236 .iter()
1237 .filter(|f| f.path.starts_with("extensions"))
1238 .filter_map(|f| {
1239 f.path
1240 .file_name()
1241 .and_then(|s| s.to_str())
1242 .map(str::to_owned)
1243 })
1244 .collect(),
1245 }
1246}
1247fn observe(root: &Path, scope: Scope, r: &Receipt, busy: bool) -> Result<Value> {
1248 observe_bounded(root, scope, r, busy, &mut InventoryBudget::default())
1249}
1250fn observe_bounded(
1251 root: &Path,
1252 scope: Scope,
1253 r: &Receipt,
1254 busy: bool,
1255 budget: &mut InventoryBudget,
1256) -> Result<Value> {
1257 let observed = inventory_bounded(&root.join(&r.target), budget);
1258 observe_inventory(root, scope, r, busy, observed)
1259}
1260fn observe_inventory(
1261 root: &Path,
1262 scope: Scope,
1263 r: &Receipt,
1264 busy: bool,
1265 observed: Result<Inventory>,
1266) -> Result<Value> {
1267 let mut diagnostics =
1268 vec!["native ABI compatibility is unverified; inspection never loads code".to_string()];
1269 if observed.as_ref().ok() != Some(&r.inventory) {
1270 diagnostics.push("installed content is missing or differs from ownership receipt".into());
1271 }
1272 if let Err(e) = &observed {
1273 diagnostics.push(e.clone());
1274 }
1275 Ok(
1276 json!({"kind":r.kind,"scope":scope,"identity":format!("{}:{}:{}",if scope==Scope::Project{"project"}else{"global"},r.kind.dir(),r.name),"name":r.name,"source":r.source,"version":r.version,"ownership":"resource","installed":true,"inUse":busy,"loaded":null,"applied":null,"path":root.join(&r.target),"selectable":selectable(root,scope,r),"recordedDigest":r.inventory.sha256,"observed":observed.ok(),"diagnostics":diagnostics}),
1277 )
1278}
1279fn selectable(root: &Path, scope: Scope, r: &Receipt) -> Value {
1280 match r.kind {
1281 Kind::Package => json!({"main_package":{"name":r.name,"scope":scope,"instructions":[]}}),
1282 Kind::Skill => json!({"skills":[root.join(&r.target)]}),
1283 Kind::Extension => json!({"extensions":[root.join(&r.target)]}),
1284 }
1285}
1286fn catalog(root: &Path, o: &Options) -> Result<Value> {
1287 checked_path(root)?;
1288 let (_observation_lease, busy) = observation_lease(root)?;
1289 let r = receipts(root)?;
1290 let p = packages(root)?;
1291 let native: Vec<crate::install::InstalledNativePackage> =
1292 read_json(&root.join("native-packages.json"))?;
1293 let scope = o.scope.unwrap();
1294 let mut items = Vec::new();
1295 let mut owned = BTreeSet::new();
1296 let mut budget = InventoryBudget::default();
1297 for receipt in &r.resources {
1298 owned.insert(receipt.target.clone());
1299 items.push(observe_bounded(root, scope, receipt, busy, &mut budget)?);
1300 }
1301 for kind in [Kind::Package, Kind::Skill, Kind::Extension] {
1302 let directory = root.join(kind.dir());
1303 checked_path(&directory)?;
1304 let entries = match fs::read_dir(directory) {
1305 Ok(e) => e,
1306 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
1307 Err(e) => return Err(err(e)),
1308 };
1309 for entry in entries {
1310 let entry = entry.map_err(err)?;
1311 let target = Path::new(kind.dir()).join(entry.file_name());
1312 if owned.contains(&target) {
1313 continue;
1314 }
1315 if items.len() >= MAX_FILES {
1316 return Err("catalog exceeds entry limit".into());
1317 }
1318 let name = if kind == Kind::Extension {
1319 entry
1320 .path()
1321 .file_stem()
1322 .and_then(|s| s.to_str())
1323 .unwrap_or("")
1324 .to_string()
1325 } else {
1326 entry
1327 .file_name()
1328 .to_str()
1329 .ok_or("non UTF8 candidate")?
1330 .to_string()
1331 };
1332 let pkg = p
1333 .packages
1334 .iter()
1335 .find(|p| kind == Kind::Package && p.name == name);
1336 let ext = native.iter().find(|p| {
1337 kind == Kind::Extension
1338 && p.artifacts
1339 .iter()
1340 .any(|a| entry.file_name() == std::ffi::OsStr::new(a))
1341 });
1342 let inv = inventory_bounded(&entry.path(), &mut budget);
1343 let diagnostics = match &inv {
1344 Ok(_) => vec!["unverified candidate; no code executed".to_string()],
1345 Err(e) => vec![e.clone()],
1346 };
1347 items.push(json!({"kind":kind,"scope":scope,"identity":format!("{}:{}:{}",if scope==Scope::Project{"project"}else{"global"},kind.dir(),name),"name":name,"source":pkg.map(|p|p.source.clone()).or_else(||ext.and_then(|p|p.source.clone())),"version":pkg.and_then(|p|if p.version.is_empty(){None}else{Some(&p.version)}).or_else(||ext.filter(|p|!p.version.trim().is_empty()).map(|p|&p.version)),"ownership":if pkg.is_some()||ext.is_some(){"legacy-native"}else{"unmanaged"},"installed":pkg.is_some()||ext.is_some(),"inUse":busy,"loaded":null,"applied":null,"path":entry.path(),"observed":inv.ok(),"diagnostics":diagnostics}));
1348 }
1349 }
1350 if o.op == "inspect" {
1351 items.retain(|item| item["name"] == json!(o.name) && item["kind"] == json!(o.kind));
1352 if items.is_empty() {
1353 return Err("resource not found".into());
1354 }
1355 }
1356 items.sort_by_key(|i| i["identity"].as_str().unwrap_or("").to_string());
1357 if _observation_lease.is_none() && root.join(LEASE).try_exists().map_err(err)? {
1361 return Err("resource ownership initialized during observation; retry catalog".into());
1362 }
1363 Ok(
1364 json!({"outcome":"observed","resources":items,"diagnostics":if root.join(JOURNAL).try_exists().map_err(err)?{vec!["incomplete transaction; run resource recover"]}else{vec![]}}),
1365 )
1366}
1367fn success(o: &Options, data: Value) -> Value {
1368 json!({"version":1,"success":true,"operation":o.op,"scope":o.scope,"kind":o.kind,"name":o.name,"data":data})
1369}
1370pub fn run(args: &[String]) -> i32 {
1371 if args.is_empty() || args.iter().any(|a| matches!(a.as_str(), "--help" | "-h")) {
1372 println!("rpi resource list|inspect|install|update|reinstall|remove|recover --scope project|global\nMutations: KIND NAME --path PATH [--version TEXT]; remove/inspect: KIND NAME.\nLocal snapshots only; no builds or native registration. JSON v1, 256 KiB bound.");
1373 return 0;
1374 }
1375 let result: Result<Value> = (|| {
1376 let o = parse(args)?;
1377 let cwd = std::env::current_dir().map_err(err)?;
1378 let root = o.scope.unwrap().root(&cwd)?;
1379 let data = if matches!(o.op.as_str(), "list" | "inspect") {
1380 catalog(&root, &o)?
1381 } else if o.op == "recover" {
1382 let _lease = Lease::acquire(&root, true, true)?;
1383 json!({"outcome":if recover(&root)?{"recovered"}else{"unchanged"}})
1384 } else {
1385 mutate(&root, &o)?
1386 };
1387 Ok(success(&o, data))
1388 })();
1389 match result.and_then(|v| json_bytes(&v)) {
1390 Ok(bytes) => {
1391 println!("{}", String::from_utf8_lossy(&bytes));
1392 0
1393 }
1394 Err(e) => {
1395 let bounded = e.chars().take(2048).collect::<String>();
1396 let context = parse(args).ok();
1397 println!(
1398 "{}",
1399 json!({"version":1,"success":false,"operation":context.as_ref().map(|o|&o.op),"scope":context.as_ref().and_then(|o|o.scope),"kind":context.as_ref().and_then(|o|o.kind),"name":context.as_ref().and_then(|o|o.name.as_ref()),"error":bounded})
1400 );
1401 1
1402 }
1403 }
1404}
1405
1406#[cfg(test)]
1407mod regressions;
1408#[cfg(test)]
1409mod tests;