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