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