Skip to main content

rpi_cli/
resource_management.rs

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