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