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}
363fn ensure_complete(root: &Path) -> Result<()> {
364 if read_bytes(&root.join(JOURNAL), MAX_META)?.is_some() {
365 return Err(
366 "incomplete resource transaction; run rpi resource recover with this scope".into(),
367 );
368 }
369 Ok(())
370}
371pub(crate) fn host_leases(cwd: &Path) -> Result<Arc<Vec<Lease>>> {
372 let mut roots = vec![Scope::Project.root(cwd)?, Scope::Global.root(cwd)?];
373 roots.sort();
374 roots.dedup();
375 let mut leases = Vec::new();
376 for root in roots {
377 leases.push(Lease::acquire(&root, false, true)?.ok_or("missing resource lease")?);
378 ensure_complete(&root)?;
379 }
380 Ok(Arc::new(leases))
381}
382fn observation_lease(root: &Path) -> Result<(Option<Lease>, bool)> {
383 match Lease::acquire(root, true, false) {
386 Ok(lease) => Ok((lease, false)),
387 Err(LeaseError::Contended) => Ok((Lease::acquire(root, false, false)?, true)),
388 Err(e) => Err(e.into()),
389 }
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
393#[serde(deny_unknown_fields)]
394struct ResourceFile {
395 path: PathBuf,
396 bytes: u64,
397 sha256: String,
398}
399#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
400#[serde(deny_unknown_fields)]
401struct Inventory {
402 files: Vec<ResourceFile>,
403 directories: Vec<PathBuf>,
404 sha256: String,
405}
406#[derive(Default)]
407struct InventoryBudget {
408 entries: usize,
409 bytes: u64,
410}
411fn inventory(path: &Path) -> Result<Inventory> {
412 inventory_bounded(path, &mut InventoryBudget::default())
413}
414fn inventory_bounded(path: &Path, budget: &mut InventoryBudget) -> Result<Inventory> {
415 checked_path(path)?;
416 let mut files = Vec::new();
417 let mut directories = Vec::new();
418 fn walk(
419 root: &Path,
420 path: &Path,
421 files: &mut Vec<ResourceFile>,
422 dirs: &mut Vec<PathBuf>,
423 budget: &mut InventoryBudget,
424 depth: usize,
425 ) -> Result<()> {
426 if depth > 32 {
427 return Err("resource recursion depth exceeds 32".into());
428 }
429 if budget.entries >= MAX_FILES {
430 return Err("resource inventory exceeds 2048 entries".into());
431 }
432 budget.entries += 1;
433 let meta = fs::symlink_metadata(path).map_err(err)?;
434 if is_link(&meta) {
435 return Err(format!(
436 "resource link/reparse point rejected: {}",
437 path.display()
438 ));
439 }
440 let rel = path.strip_prefix(root).map_err(err)?;
441 if !rel.as_os_str().is_empty() {
442 relative(rel)?;
443 }
444 if meta.is_dir() {
445 if !rel.as_os_str().is_empty() {
446 dirs.push(rel.to_path_buf());
447 }
448 let remaining = MAX_FILES - budget.entries;
449 let mut entries = Vec::new();
450 for entry in fs::read_dir(path).map_err(err)?.take(remaining + 1) {
451 if entries.len() == remaining {
452 return Err("resource directory exceeds remaining entry budget".into());
453 }
454 entries.push(entry.map_err(err)?);
455 }
456 entries.sort_by_key(|e| e.file_name());
457 for e in entries {
458 walk(root, &e.path(), files, dirs, budget, depth + 1)?;
459 }
460 } else if meta.is_file() {
461 if meta.len() > MAX_FILE || budget.bytes + meta.len() > MAX_BYTES {
462 return Err("resource files exceed byte limit (64 MiB/file, 256 MiB total)".into());
463 }
464 let mut input = File::open(path).map_err(err)?;
465 let mut hash = Sha256::new();
466 let mut size = 0;
467 let mut buf = [0; 16384];
468 loop {
469 let n = input.read(&mut buf).map_err(err)?;
470 if n == 0 {
471 break;
472 }
473 size += n as u64;
474 budget.bytes += n as u64;
475 if size > MAX_FILE || budget.bytes > MAX_BYTES {
476 return Err("resource changed/exceeded byte limit".into());
477 }
478 hash.update(&buf[..n]);
479 }
480 if size != meta.len() {
481 return Err("resource changed while reading".into());
482 }
483 files.push(ResourceFile {
484 path: if rel.as_os_str().is_empty() {
485 PathBuf::from(".")
486 } else {
487 rel.into()
488 },
489 bytes: size,
490 sha256: format!("{:x}", hash.finalize()),
491 });
492 } else {
493 return Err("special resource file rejected".into());
494 }
495 Ok(())
496 }
497 walk(path, path, &mut files, &mut directories, budget, 0)?;
498 let sha256 = format!(
499 "{:x}",
500 Sha256::digest(serde_json::to_vec(&(&files, &directories)).map_err(err)?)
501 );
502 Ok(Inventory {
503 files,
504 directories,
505 sha256,
506 })
507}
508#[derive(Debug, Clone, Serialize, Deserialize)]
509#[serde(deny_unknown_fields)]
510struct Receipt {
511 kind: Kind,
512 name: String,
513 source: PathBuf,
514 version: Option<String>,
515 target: PathBuf,
516 inventory: Inventory,
517}
518#[derive(Default, Serialize, Deserialize)]
519#[serde(deny_unknown_fields)]
520struct Receipts {
521 #[serde(default)]
522 resources: Vec<Receipt>,
523}
524fn receipts(root: &Path) -> Result<Receipts> {
525 let r: Receipts = read_json(&root.join(RECEIPTS))?;
526 let mut keys = BTreeSet::new();
527 if r.resources.len() > 128 {
528 return Err("resource receipts exceed 128 resources per scope".into());
529 }
530 for item in &r.resources {
531 identifier(&item.name)?;
532 if item.version.as_ref().is_some_and(|v| {
533 v.trim().is_empty() || v.len() > 128 || v.chars().any(char::is_control)
534 }) {
535 return Err("invalid resource receipt version".into());
536 }
537 if item.target != item.kind.target(&item.name)
538 || !item.source.is_absolute()
539 || !keys.insert(item.target.to_string_lossy().to_ascii_lowercase())
540 {
541 return Err("unsafe/duplicate resource receipt".into());
542 }
543 checked_path(&root.join(&item.target))?;
544 if item.inventory.files.len() + item.inventory.directories.len() > MAX_FILES {
545 return Err("oversized receipt inventory".into());
546 }
547 for file in &item.inventory.files {
548 if file.path != Path::new(".") {
549 relative(&file.path)?;
550 }
551 }
552 for dir in &item.inventory.directories {
553 relative(dir)?;
554 }
555 }
556 Ok(r)
557}
558#[derive(Default, Serialize, Deserialize)]
559#[serde(deny_unknown_fields)]
560struct Packages {
561 #[serde(default)]
562 packages: Vec<crate::packages::ProjectPackage>,
563}
564fn packages(root: &Path) -> Result<Packages> {
565 let p: Packages = read_json(&root.join("packages.json"))?;
566 let mut names = BTreeSet::new();
567 for item in &p.packages {
568 legacy_package_identity(&item.name)?;
569 if !names.insert(item.name.to_ascii_lowercase()) {
570 return Err("duplicate native package identity".into());
571 }
572 let mut artifacts = BTreeSet::new();
573 for a in &item.artifacts {
574 relative(Path::new(a))?;
575 reserved_identity(a)?;
576 if Path::new(a).components().count() != 1 || !artifacts.insert(a.to_ascii_lowercase()) {
577 return Err("unsafe/duplicate native artifact".into());
578 }
579 }
580 }
581 Ok(p)
582}
583pub(crate) fn legacy_unowned(root: &Path, kind: &str, name: &str) -> Result<()> {
584 let kind = Kind::parse(kind)?;
585 if receipts(root)?
586 .resources
587 .iter()
588 .any(|r| r.kind == kind && r.name.eq_ignore_ascii_case(name))
589 {
590 return Err("resource is managed by rpi resource; use its update/remove commands".into());
591 }
592 if kind == Kind::Package {
593 legacy_package_identity(name)?;
594 let package = root.join(kind.target(name));
595 legacy_unowned_targets(root, &[package.clone(), package.join("extensions")])?;
596 }
597 Ok(())
598}
599fn path_identity(path: &Path) -> Result<PathBuf> {
600 let path = if path.is_absolute() {
601 path.to_path_buf()
602 } else {
603 std::env::current_dir().map_err(err)?.join(path)
604 };
605 checked_path(&path)?;
606 let mut ancestor = path.as_path();
609 let mut tail = Vec::new();
610 while !ancestor.try_exists().map_err(err)? {
611 tail.push(
612 ancestor
613 .file_name()
614 .ok_or("no path ancestor")?
615 .to_os_string(),
616 );
617 ancestor = ancestor.parent().ok_or("no path ancestor")?;
618 }
619 let mut identity = fs::canonicalize(ancestor).map_err(err)?;
620 for component in tail.into_iter().rev() {
621 identity.push(component);
622 }
623 if cfg!(windows) {
624 Ok(PathBuf::from(identity.to_string_lossy().to_lowercase()))
625 } else {
626 Ok(identity)
627 }
628}
629pub(crate) fn resource_owned_names(root: &Path, kind: &str) -> Result<BTreeSet<String>> {
630 let kind = Kind::parse(kind)?;
631 Ok(receipts(root)?
632 .resources
633 .into_iter()
634 .filter(|r| r.kind == kind)
635 .map(|r| r.name)
636 .collect())
637}
638pub(crate) fn legacy_unowned_targets(root: &Path, targets: &[PathBuf]) -> Result<()> {
639 let targets = targets
640 .iter()
641 .map(|t| path_identity(t))
642 .collect::<Result<Vec<_>>>()?;
643 for r in receipts(root)?.resources {
644 let owned = path_identity(&root.join(&r.target))?;
645 if targets.iter().any(|t| t == &owned || t.starts_with(&owned)) {
646 return Err("target owned by rpi resource; refusing legacy replacement/removal".into());
647 }
648 }
649 Ok(())
650}
651
652#[derive(Serialize, Deserialize)]
653#[serde(deny_unknown_fields)]
654struct Change {
655 target: PathBuf,
656 old: Option<Inventory>,
657 new: Option<Inventory>,
658}
659#[derive(Serialize, Deserialize)]
660#[serde(deny_unknown_fields)]
661struct Journal {
662 directory: String,
663 changes: Vec<Change>,
664}
665fn transaction_target(p: &Path) -> Result<()> {
666 relative(p)?;
667 if p == Path::new(RECEIPTS) || p == Path::new("packages.json") {
668 return Ok(());
669 }
670 let parts = p
671 .iter()
672 .map(|p| p.to_str().ok_or("non-UTF8 target"))
673 .collect::<std::result::Result<Vec<_>, _>>()?;
674 if parts.len() != 2 || !matches!(parts[0], "packages" | "skills" | "extensions") {
675 return Err("unsafe transaction target".into());
676 }
677 identifier(parts[1])
678}
679fn remove_path(path: &Path) -> Result<()> {
680 checked_path(path)?;
681 let m = fs::symlink_metadata(path).map_err(err)?;
682 if m.is_dir() {
683 fs::remove_dir_all(path).map_err(err)
684 } else if m.is_file() {
685 fs::remove_file(path).map_err(err)
686 } else {
687 Err("unsafe removal".into())
688 }
689}
690fn rollback(root: &Path, j: &Journal) -> Result<()> {
691 let dir = root.join(&j.directory);
692 checked_path(&dir)?;
693 for (i, c) in j.changes.iter().enumerate() {
695 transaction_target(&c.target)?;
696 let target = root.join(&c.target);
697 let backup = dir.join(format!("old-{i}"));
698 if backup.try_exists().map_err(err)? {
699 if Some(inventory(&backup)?) != c.old {
700 return Err("transaction backup changed; manual recovery required".into());
701 }
702 if target.try_exists().map_err(err)? && Some(inventory(&target)?) != c.new {
703 return Err("transaction target changed; refusing recovery deletion".into());
704 }
705 } else if target.try_exists().map_err(err)? {
706 let observed = inventory(&target)?;
707 if c.old.as_ref() != Some(&observed) && c.new.as_ref() != Some(&observed) {
708 return Err("transaction target differs from old/new inventory".into());
709 }
710 if c.old.is_some() && c.old.as_ref() != Some(&observed) {
711 return Err("missing rollback backup; manual recovery required".into());
712 }
713 } else if c.old.is_some() {
714 return Err("missing original/backup; manual recovery required".into());
715 }
716 }
717 for (i, c) in j.changes.iter().enumerate().rev() {
718 let target = root.join(&c.target);
719 let backup = dir.join(format!("old-{i}"));
720 if backup.try_exists().map_err(err)? {
721 if target.try_exists().map_err(err)? {
722 remove_path(&target)?;
723 }
724 fs::rename(&backup, &target).map_err(err)?;
725 } else if c.old.is_none() && target.try_exists().map_err(err)? {
726 remove_path(&target)?;
727 }
728 }
729 fs::remove_file(root.join(JOURNAL)).map_err(err)?;
731 fs::remove_dir_all(dir).map_err(err)
732}
733fn recover(root: &Path) -> Result<bool> {
734 let Some(bytes) = read_bytes(&root.join(JOURNAL), MAX_META)? else {
735 return Ok(false);
736 };
737 let j: Journal = serde_json::from_slice(&bytes).map_err(err)?;
738 let suffix = j
739 .directory
740 .strip_prefix(".resource-txn-")
741 .ok_or("invalid transaction directory")?;
742 uuid::Uuid::parse_str(suffix).map_err(err)?;
743 if j.changes.len() > 3 {
744 return Err("oversized transaction plan".into());
745 }
746 let mut seen = BTreeSet::new();
747 for c in &j.changes {
748 transaction_target(&c.target)?;
749 if !seen.insert(&c.target) {
750 return Err("duplicate transaction target".into());
751 }
752 }
753 rollback(root, &j)?;
754 Ok(true)
755}
756fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
757 let mut f = OpenOptions::new()
758 .write(true)
759 .create_new(true)
760 .open(path)
761 .map_err(err)?;
762 f.write_all(bytes).and_then(|_| f.sync_all()).map_err(err)
763}
764fn copy_snapshot(source: &Path, target: &Path, observed: &Inventory) -> Result<()> {
765 fn copy_file(source: &Path, target: &Path, expected: u64) -> Result<()> {
766 checked_path(source)?;
767 let input = File::open(source).map_err(err)?;
768 #[cfg(unix)]
769 let mode = {
770 use std::os::unix::fs::PermissionsExt;
771 input.metadata().map_err(err)?.permissions().mode() & 0o777
773 };
774 let mut output = OpenOptions::new()
775 .write(true)
776 .create_new(true)
777 .open(target)
778 .map_err(err)?;
779 let copied = std::io::copy(&mut input.take(expected + 1), &mut output).map_err(err)?;
780 if copied != expected {
781 return Err("resource changed during bounded copy".into());
782 }
783 #[cfg(unix)]
784 {
785 use std::os::unix::fs::PermissionsExt;
786 output
787 .set_permissions(fs::Permissions::from_mode(mode))
788 .map_err(err)?;
789 }
790 output.sync_all().map_err(err)
791 }
792 if source.is_file() {
793 copy_file(
794 source,
795 target,
796 observed
797 .files
798 .first()
799 .ok_or("missing file inventory")?
800 .bytes,
801 )?;
802 } else {
803 fs::create_dir(target).map_err(err)?;
804 for d in &observed.directories {
805 fs::create_dir_all(target.join(d)).map_err(err)?;
806 }
807 for f in &observed.files {
808 let to = target.join(&f.path);
809 if let Some(p) = to.parent() {
810 fs::create_dir_all(p).map_err(err)?;
811 }
812 copy_file(&source.join(&f.path), &to, f.bytes)?;
813 }
814 }
815 if inventory(target)? != *observed || inventory(source)? != *observed {
816 return Err("resource source changed during snapshot copy".into());
817 }
818 Ok(())
819}
820fn publish(
822 root: &Path,
823 changes: Vec<(PathBuf, Option<PathBuf>)>,
824 stage: tempfile::TempDir,
825 mut before: impl FnMut(usize) -> Result<()>,
826) -> Result<Vec<String>> {
827 let directory = stage
828 .path()
829 .file_name()
830 .and_then(|s| s.to_str())
831 .ok_or("invalid staging directory")?
832 .to_owned();
833 let mut j = Journal {
834 directory,
835 changes: Vec::new(),
836 };
837 for (target, new) in &changes {
838 transaction_target(target)?;
839 let path = root.join(target);
840 checked_path(&path)?;
841 j.changes.push(Change {
842 target: target.clone(),
843 old: if path.try_exists().map_err(err)? {
844 Some(inventory(&path)?)
845 } else {
846 None
847 },
848 new: new.as_ref().map(|p| inventory(p)).transpose()?,
849 });
850 }
851 let journal_stage = stage.path().join("journal");
852 write_new(&journal_stage, &json_bytes(&j)?)?;
853 fs::rename(&journal_stage, root.join(JOURNAL)).map_err(err)?;
854 let _owned = stage.keep();
856 let result = (|| {
857 for (i, (target, new)) in changes.iter().enumerate() {
858 before(i * 2)?;
859 let path = root.join(target);
860 if j.changes[i].old.is_some() {
861 fs::rename(&path, root.join(&j.directory).join(format!("old-{i}"))).map_err(err)?;
862 }
863 before(i * 2 + 1)?;
864 if let Some(new) = new {
865 fs::rename(new, &path).map_err(err)?;
866 }
867 }
868 before(changes.len() * 2)?;
869 Ok(())
870 })();
871 if let Err(error) = result {
872 return match rollback(root, &j) {
873 Ok(()) => Err(error),
874 Err(r) => Err(format!(
875 "{error}; rollback incomplete: {r}; run resource recover"
876 )),
877 };
878 }
879 if let Err(error) = fs::remove_file(root.join(JOURNAL)) {
882 return match rollback(root,&j) {
883 Ok(())=>Err(format!("commit marker removal failed: {error}; rolled back")),
884 Err(rollback)=>Err(format!("commit marker removal failed: {error}; rollback incomplete: {rollback}; run resource recover")),
885 };
886 }
887 if let Err(e) = fs::remove_dir_all(root.join(&j.directory)) {
888 return Ok(vec![format!(
889 "committed; backup cleanup pending (OS error {:?}, {:?})",
890 e.raw_os_error(),
891 e.kind()
892 )]);
893 }
894 Ok(Vec::new())
895}
896fn stage_dir(root: &Path) -> Result<tempfile::TempDir> {
897 tempfile::Builder::new()
898 .prefix(&format!(".resource-txn-{}", uuid::Uuid::new_v4().simple()))
899 .rand_bytes(0)
900 .tempdir_in(root)
901 .map_err(err)
902}
903
904#[derive(Default)]
905struct Options {
906 op: String,
907 kind: Option<Kind>,
908 name: Option<String>,
909 scope: Option<Scope>,
910 source: Option<PathBuf>,
911 version: Option<String>,
912}
913fn parse(args: &[String]) -> Result<Options> {
914 let mut o = Options {
915 op: args.first().ok_or("missing resource operation")?.clone(),
916 ..Default::default()
917 };
918 let mut pos = Vec::new();
919 let mut i = 1;
920 while i < args.len() {
921 let a = &args[i];
922 if matches!(a.as_str(), "--scope" | "--path" | "--version") {
923 i += 1;
924 let v = args.get(i).ok_or("missing option value")?;
925 if v.starts_with('-') {
926 return Err("missing option value".into());
927 }
928 match a.as_str() {
929 "--scope" => {
930 if o.scope.is_some() {
931 return Err("duplicate scope".into());
932 }
933 o.scope = Some(match v.as_str() {
934 "project" => Scope::Project,
935 "global" => Scope::Global,
936 _ => return Err("scope must be project or global".into()),
937 });
938 }
939 "--path" => {
940 if o.source.replace(v.into()).is_some() {
941 return Err("duplicate path".into());
942 }
943 }
944 _ => {
945 if v.trim().is_empty()
946 || v.len() > 128
947 || v.chars().any(char::is_control)
948 || o.version.replace(v.clone()).is_some()
949 {
950 return Err("invalid/duplicate version".into());
951 }
952 }
953 }
954 } else if a.starts_with('-') {
955 return Err(format!(
956 "unknown resource option {a}; builds require explicit native package link/install"
957 ));
958 } else {
959 pos.push(a.clone());
960 }
961 i += 1;
962 }
963 if o.scope.is_none() {
964 return Err("explicit --scope project|global is required".into());
965 }
966 if matches!(o.op.as_str(), "list" | "recover") {
967 if !pos.is_empty() || o.source.is_some() || o.version.is_some() {
968 return Err("unexpected list/recover arguments".into());
969 }
970 } else if matches!(
971 o.op.as_str(),
972 "inspect" | "install" | "update" | "reinstall" | "remove"
973 ) {
974 if pos.len() != 2 {
975 return Err("expected KIND NAME".into());
976 }
977 o.kind = Some(Kind::parse(&pos[0])?);
978 if o.op == "inspect" && o.kind == Some(Kind::Package) {
979 legacy_package_identity(&pos[1])?;
980 } else {
981 identifier(&pos[1])?;
982 }
983 o.name = Some(pos[1].clone());
984 let mutation = matches!(o.op.as_str(), "install" | "update" | "reinstall");
985 if mutation != o.source.is_some() || (!mutation && o.version.is_some()) {
986 return Err("install/update/reinstall require --path; inspect/remove reject it".into());
987 }
988 } else {
989 return Err("unknown resource operation".into());
990 }
991 Ok(o)
992}
993fn validate_source(kind: Kind, source: &Path, inv: &Inventory) -> Result<()> {
994 match kind {
995 Kind::Skill => {
996 if !source.is_dir() || !source.join("SKILL.md").is_file() {
997 return Err("standalone Skill requires a directory containing SKILL.md".into());
998 }
999 let text =
1000 read_bytes(&source.join("SKILL.md"), 64 * 1024)?.ok_or("missing SKILL.md")?;
1001 std::str::from_utf8(&text).map_err(err)?;
1002 }
1003 Kind::Extension => {
1004 if !source.is_file()
1005 || source.extension().and_then(|s| s.to_str())
1006 != Some(std::env::consts::DLL_EXTENSION)
1007 {
1008 return Err("extension source must be a platform native library file".into());
1009 }
1010 }
1011 Kind::Package => {
1012 if !source.is_dir() {
1013 return Err("package source must be a directory".into());
1014 }
1015 if !["extensions", "skills", "prompts", "themes"]
1016 .iter()
1017 .any(|s| source.join(s).is_dir())
1018 && inv.files.is_empty()
1019 {
1020 return Err("empty package".into());
1021 }
1022 for f in &inv.files {
1023 if f.path.starts_with("extensions")
1024 && (f.path.components().count() != 2
1025 || f.path.extension().and_then(|s| s.to_str())
1026 != Some(std::env::consts::DLL_EXTENSION))
1027 {
1028 return Err(
1029 "Package extensions must be direct platform native artifacts".into(),
1030 );
1031 }
1032 }
1033 }
1034 }
1035 Ok(())
1036}
1037fn explicit_source(path: &Path) -> Result<PathBuf> {
1038 let addressed = std::env::current_dir().map_err(err)?.join(path);
1039 let mut normalized = PathBuf::new();
1040 for component in addressed.components() {
1041 match component {
1042 Component::CurDir => {}
1043 Component::ParentDir => {
1044 checked_path(&normalized)?;
1045 if !normalized.pop() {
1046 return Err("source escapes filesystem root".into());
1047 }
1048 }
1049 other => {
1050 normalized.push(other);
1051 if !matches!(other, Component::Prefix(_)) && normalized.is_absolute() {
1054 checked_path(&normalized)?;
1055 }
1056 }
1057 }
1058 }
1059 fs::canonicalize(normalized).map_err(err)
1060}
1061fn mutate(root: &Path, o: &Options) -> Result<Value> {
1062 let _lease = mutation_lease(root)?;
1063 let mut r = receipts(root)?;
1064 let mut p = packages(root)?;
1065 let native: Vec<crate::install::InstalledNativePackage> =
1066 read_json(&root.join("native-packages.json"))?;
1067 let kind = o.kind.ok_or("missing kind")?;
1068 let name = o.name.as_deref().ok_or("missing name")?;
1069 let target = kind.target(name);
1070 let path = root.join(&target);
1071 checked_path(&path)?;
1072 let previous = r
1073 .resources
1074 .iter()
1075 .find(|r| r.kind == kind && r.name.eq_ignore_ascii_case(name))
1076 .cloned();
1077 for owned in r.resources.iter().filter(|r| r.kind == Kind::Package) {
1078 if p.packages.iter().find(|p| p.name == owned.name) != Some(&package_record(owned)) {
1079 return Err(
1080 "owned package receipt disagrees with native packages.json; refusing mutation"
1081 .into(),
1082 );
1083 }
1084 }
1085 if kind == Kind::Extension
1086 && native.iter().any(|p| {
1087 p.name.eq_ignore_ascii_case(name)
1088 || p.artifacts.iter().any(|a| {
1089 target
1090 .file_name()
1091 .is_some_and(|f| f.to_string_lossy().eq_ignore_ascii_case(a))
1092 })
1093 })
1094 {
1095 return Err(
1096 "extension identity/artifact owned by legacy native registry (even if missing)".into(),
1097 );
1098 }
1099 if let Some(old) = &previous {
1100 if old.name != name {
1101 return Err("identity case collision".into());
1102 }
1103 if inventory(&path)? != old.inventory {
1104 return Err("owned resource content changed; refusing overwrite/removal".into());
1105 }
1106 }
1107 if o.op == "remove" && previous.is_none() {
1108 if path.try_exists().map_err(err)? {
1109 return Err("unmanaged destination: refusing removal".into());
1110 }
1111 return Ok(json!({"outcome":"absent","kind":kind,"name":name}));
1112 }
1113 if previous.is_none() && path.try_exists().map_err(err)? {
1114 return Err("unmanaged destination conflict".into());
1115 }
1116 if kind == Kind::Package
1117 && previous.is_none()
1118 && p.packages.iter().any(|p| p.name.eq_ignore_ascii_case(name))
1119 {
1120 return Err("package identity owned by native package link".into());
1121 }
1122 if matches!(o.op.as_str(), "update" | "reinstall") && previous.is_none() {
1123 return Err("resource is not owned/installed; use install".into());
1124 }
1125 let source = o
1126 .source
1127 .as_ref()
1128 .map(|path| explicit_source(path))
1129 .transpose()?;
1130 if let Some(source) = &source {
1131 let source_identity = path_identity(source)?;
1132 let root_identity = path_identity(root)?;
1133 if source_identity.starts_with(&root_identity)
1134 || root_identity.starts_with(&source_identity)
1135 {
1136 return Err("source/destination resource trees overlap".into());
1137 }
1138 }
1139 let stage = stage_dir(root)?;
1140 let mut next = None;
1141 if let Some(source) = source {
1142 let inv = inventory(&source)?;
1144 validate_source(kind, &source, &inv)?;
1145 let version = o
1146 .version
1147 .clone()
1148 .or_else(|| previous.as_ref().and_then(|r| r.version.clone()));
1149 let receipt = Receipt {
1150 kind,
1151 name: name.into(),
1152 source,
1153 version,
1154 target: target.clone(),
1155 inventory: inv,
1156 };
1157 if o.op == "install" {
1158 if let Some(old) = &previous {
1159 if old.source == receipt.source
1160 && old.version == receipt.version
1161 && old.inventory == receipt.inventory
1162 {
1163 return Ok(
1164 json!({"outcome":"unchanged","resource":observe(root,o.scope.unwrap(),old,false)?}),
1165 );
1166 }
1167 return Err("already installed; use update or reinstall with --path".into());
1168 }
1169 }
1170 let staged = stage.path().join("snapshot");
1171 copy_snapshot(&receipt.source, &staged, &receipt.inventory)?;
1172 next = Some((receipt, staged));
1173 }
1174 r.resources.retain(|r| !(r.kind == kind && r.name == name));
1175 if kind == Kind::Package {
1176 p.packages.retain(|p| p.name != name);
1177 if let Some((new, _)) = &next {
1178 p.packages.push(package_record(new));
1179 }
1180 }
1181 if let Some((receipt, _)) = &next {
1182 r.resources.push(receipt.clone());
1183 }
1184 if r.resources.len() > 128 {
1185 return Err("resource receipts exceed 128 resources per scope".into());
1186 }
1187 r.resources.sort_by_key(|r| r.target.clone());
1188 p.packages.sort_by(|a, b| a.name.cmp(&b.name));
1189 let receipt_stage = stage.path().join("receipts");
1190 write_new(&receipt_stage, &json_bytes(&r)?)?;
1191 let mut changes = vec![
1192 (target, next.as_ref().map(|(_, p)| p.clone())),
1193 (PathBuf::from(RECEIPTS), Some(receipt_stage)),
1194 ];
1195 if kind == Kind::Package {
1196 let staged = stage.path().join("packages");
1197 write_new(&staged, &json_bytes(&p)?)?;
1198 changes.push((PathBuf::from("packages.json"), Some(staged)));
1199 }
1200 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()?});
1201 if json_bytes(&success(o, data.clone()))
1204 .map_err(|error| format!("resource success result rejected before publication: {error}"))?
1205 .len()
1206 > MAX_META - 4096
1207 {
1208 return Err("resource success result exceeds bounded publication budget".into());
1209 }
1210 let parent = path.parent().ok_or("no resource parent")?;
1211 checked_path(parent)?;
1212 fs::create_dir_all(parent).map_err(err)?;
1213 let diagnostics = publish(root, changes, stage, |_| Ok(()))?;
1214 data["diagnostics"] = json!(diagnostics);
1215 Ok(data)
1216}
1217fn package_record(r: &Receipt) -> crate::packages::ProjectPackage {
1218 crate::packages::ProjectPackage {
1219 name: r.name.clone(),
1220 version: r.version.clone().unwrap_or_default(),
1221 source: r.source.to_string_lossy().into_owned(),
1222 cargo_package: String::new(),
1223 artifacts: r
1224 .inventory
1225 .files
1226 .iter()
1227 .filter(|f| f.path.starts_with("extensions"))
1228 .filter_map(|f| {
1229 f.path
1230 .file_name()
1231 .and_then(|s| s.to_str())
1232 .map(str::to_owned)
1233 })
1234 .collect(),
1235 }
1236}
1237fn observe(root: &Path, scope: Scope, r: &Receipt, busy: bool) -> Result<Value> {
1238 observe_bounded(root, scope, r, busy, &mut InventoryBudget::default())
1239}
1240fn observe_bounded(
1241 root: &Path,
1242 scope: Scope,
1243 r: &Receipt,
1244 busy: bool,
1245 budget: &mut InventoryBudget,
1246) -> Result<Value> {
1247 let observed = inventory_bounded(&root.join(&r.target), budget);
1248 observe_inventory(root, scope, r, busy, observed)
1249}
1250fn observe_inventory(
1251 root: &Path,
1252 scope: Scope,
1253 r: &Receipt,
1254 busy: bool,
1255 observed: Result<Inventory>,
1256) -> Result<Value> {
1257 let mut diagnostics =
1258 vec!["native ABI compatibility is unverified; inspection never loads code".to_string()];
1259 if observed.as_ref().ok() != Some(&r.inventory) {
1260 diagnostics.push("installed content is missing or differs from ownership receipt".into());
1261 }
1262 if let Err(e) = &observed {
1263 diagnostics.push(e.clone());
1264 }
1265 Ok(
1266 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}),
1267 )
1268}
1269fn selectable(root: &Path, scope: Scope, r: &Receipt) -> Value {
1270 match r.kind {
1271 Kind::Package => json!({"main_package":{"name":r.name,"scope":scope,"instructions":[]}}),
1272 Kind::Skill => json!({"skills":[root.join(&r.target)]}),
1273 Kind::Extension => json!({"extensions":[root.join(&r.target)]}),
1274 }
1275}
1276fn catalog(root: &Path, o: &Options) -> Result<Value> {
1277 checked_path(root)?;
1278 let (_observation_lease, busy) = observation_lease(root)?;
1279 let r = receipts(root)?;
1280 let p = packages(root)?;
1281 let native: Vec<crate::install::InstalledNativePackage> =
1282 read_json(&root.join("native-packages.json"))?;
1283 let scope = o.scope.unwrap();
1284 let mut items = Vec::new();
1285 let mut owned = BTreeSet::new();
1286 let mut budget = InventoryBudget::default();
1287 for receipt in &r.resources {
1288 owned.insert(receipt.target.clone());
1289 items.push(observe_bounded(root, scope, receipt, busy, &mut budget)?);
1290 }
1291 for kind in [Kind::Package, Kind::Skill, Kind::Extension] {
1292 let directory = root.join(kind.dir());
1293 checked_path(&directory)?;
1294 let entries = match fs::read_dir(directory) {
1295 Ok(e) => e,
1296 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
1297 Err(e) => return Err(err(e)),
1298 };
1299 for entry in entries {
1300 let entry = entry.map_err(err)?;
1301 let target = Path::new(kind.dir()).join(entry.file_name());
1302 if owned.contains(&target) {
1303 continue;
1304 }
1305 if items.len() >= MAX_FILES {
1306 return Err("catalog exceeds entry limit".into());
1307 }
1308 let name = if kind == Kind::Extension {
1309 entry
1310 .path()
1311 .file_stem()
1312 .and_then(|s| s.to_str())
1313 .unwrap_or("")
1314 .to_string()
1315 } else {
1316 entry
1317 .file_name()
1318 .to_str()
1319 .ok_or("non UTF8 candidate")?
1320 .to_string()
1321 };
1322 let pkg = p
1323 .packages
1324 .iter()
1325 .find(|p| kind == Kind::Package && p.name == name);
1326 let ext = native.iter().find(|p| {
1327 kind == Kind::Extension
1328 && p.artifacts
1329 .iter()
1330 .any(|a| entry.file_name() == std::ffi::OsStr::new(a))
1331 });
1332 let inv = inventory_bounded(&entry.path(), &mut budget);
1333 let diagnostics = match &inv {
1334 Ok(_) => vec!["unverified candidate; no code executed".to_string()],
1335 Err(e) => vec![e.clone()],
1336 };
1337 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}));
1338 }
1339 }
1340 if o.op == "inspect" {
1341 items.retain(|item| item["name"] == json!(o.name) && item["kind"] == json!(o.kind));
1342 if items.is_empty() {
1343 return Err("resource not found".into());
1344 }
1345 }
1346 items.sort_by_key(|i| i["identity"].as_str().unwrap_or("").to_string());
1347 if _observation_lease.is_none() && root.join(LEASE).try_exists().map_err(err)? {
1351 return Err("resource ownership initialized during observation; retry catalog".into());
1352 }
1353 Ok(
1354 json!({"outcome":"observed","resources":items,"diagnostics":if root.join(JOURNAL).try_exists().map_err(err)?{vec!["incomplete transaction; run resource recover"]}else{vec![]}}),
1355 )
1356}
1357fn success(o: &Options, data: Value) -> Value {
1358 json!({"version":1,"success":true,"operation":o.op,"scope":o.scope,"kind":o.kind,"name":o.name,"data":data})
1359}
1360pub fn run(args: &[String]) -> i32 {
1361 if args.is_empty() || args.iter().any(|a| matches!(a.as_str(), "--help" | "-h")) {
1362 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.");
1363 return 0;
1364 }
1365 let result: Result<Value> = (|| {
1366 let o = parse(args)?;
1367 let cwd = std::env::current_dir().map_err(err)?;
1368 let root = o.scope.unwrap().root(&cwd)?;
1369 let data = if matches!(o.op.as_str(), "list" | "inspect") {
1370 catalog(&root, &o)?
1371 } else if o.op == "recover" {
1372 let _lease = Lease::acquire(&root, true, true)?;
1373 json!({"outcome":if recover(&root)?{"recovered"}else{"unchanged"}})
1374 } else {
1375 mutate(&root, &o)?
1376 };
1377 Ok(success(&o, data))
1378 })();
1379 match result.and_then(|v| json_bytes(&v)) {
1380 Ok(bytes) => {
1381 println!("{}", String::from_utf8_lossy(&bytes));
1382 0
1383 }
1384 Err(e) => {
1385 let bounded = e.chars().take(2048).collect::<String>();
1386 let context = parse(args).ok();
1387 println!(
1388 "{}",
1389 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})
1390 );
1391 1
1392 }
1393 }
1394}
1395
1396#[cfg(test)]
1397mod regressions;
1398#[cfg(test)]
1399mod tests;