返回 CodeWhale
audit.rs
根目录 / crates / tui / src / skills / audit.rs
1 //! Bounded, read-only skill audit inventory.
2 //!
3 //! Separates "what is on disk" from runtime [`super::SkillRegistry`] merging.
4 //! Never executes skill bodies, never contacts the network, and never writes.
5
6 use std::collections::{HashMap, HashSet};
7 use std::fs::{self, File};
8 use std::io::{Read, Take};
9 use std::path::{Path, PathBuf};
10 use std::time::SystemTime;
11
12 use serde::Deserialize;
13
14 use super::install::{INSTALLED_FROM_MARKER, TRUSTED_MARKER};
15 use super::package_digest::{self, PackageDigestError};
16 use super::roots::{
17 SkillRootAccess, SkillRootCatalog, SkillRootDescriptor, SkillRootId, SkillRootKind,
18 safe_display_path,
19 };
20 use super::system::is_exact_bundled_skill;
21 use super::{SkillRegistry, normalize_skill_name_for_lookup};
22
23 /// Max bytes of `SKILL.md` the auditor will read into memory.
24 pub const AUDIT_MAX_SKILL_MD_BYTES: u64 = 512 * 1024;
25 /// Max directory depth under a skill package (and under a root when locating packages).
26 pub const AUDIT_MAX_DEPTH: usize = package_digest::PACKAGE_DIGEST_MAX_DEPTH;
27
28 /// Which roots the auditor visits.
29 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
30 pub enum SkillAuditMode {
31 /// CodeWhale-owned project/global roots only.
32 OwnedOnly,
33 /// Owned + compatible roots (including `.codex/skills`). Does not change runtime.
34 Compatible,
35 }
36
37 /// Stable identity for one on-disk skill copy.
38 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
39 pub struct AuditedSkillId {
40 pub root_id: SkillRootId,
41 pub relative_dir: PathBuf,
42 pub canonical_name: String,
43 }
44
45 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46 pub enum SkillSourceKind {
47 CodeWhaleManaged,
48 CodeWhaleManual,
49 CompatibleExternal,
50 BuiltIn,
51 ReviewedPluginSnapshot,
52 RegistryCache,
53 }
54
55 #[derive(Debug, Clone, PartialEq, Eq)]
56 pub enum DigestUnknownReason {
57 Unreadable,
58 SymlinkPresent,
59 EscapedRoot,
60 Cycle,
61 Oversized,
62 TooManyFiles,
63 TooDeep,
64 }
65
66 #[derive(Debug, Clone, PartialEq, Eq)]
67 pub enum DigestState {
68 Known(String),
69 Unknown(DigestUnknownReason),
70 }
71
72 #[derive(Debug, Clone, PartialEq, Eq)]
73 pub enum ParserState {
74 Valid,
75 Warning(Vec<String>),
76 Broken(String),
77 Oversized,
78 }
79
80 #[derive(Debug, Clone, PartialEq, Eq)]
81 pub enum PrecedenceState {
82 Active,
83 ShadowedBy(AuditedSkillId),
84 InactiveSource,
85 Unknown,
86 }
87
88 #[derive(Debug, Clone, PartialEq, Eq)]
89 pub enum IntegrityState {
90 Healthy,
91 LocalContentDrift,
92 BrokenManagedInstall,
93 LegacyMetadataUnknown,
94 Unknown,
95 }
96
97 #[derive(Debug, Clone, PartialEq, Eq)]
98 pub enum TrustState {
99 TrustedForDigest(String),
100 TrustStale,
101 LegacyAdvisory,
102 Untrusted,
103 // Matched by the skills manager ("n/a") but never constructed: no
104 // discovery path yields a non-filesystem row yet.
105 #[allow(dead_code)]
106 NotApplicable,
107 Unknown,
108 }
109
110 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
111 pub enum ReadinessState {
112 // Constructed only in tests until the #4407 readiness cache is wired.
113 #[cfg(test)]
114 Ready,
115 Unknown,
116 }
117
118 #[derive(Debug, Clone, PartialEq, Eq)]
119 pub enum ProvenanceState {
120 Managed {
121 spec: Option<String>,
122 safe_url: Option<String>,
123 schema_version: Option<u32>,
124 },
125 Manual,
126 External,
127 BuiltIn,
128 Plugin,
129 Cache,
130 // Matched by the skills manager ("unknown") but never constructed: no
131 // discovery path yields an unclassified logical source yet.
132 #[allow(dead_code)]
133 Unknown,
134 }
135
136 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
137 pub enum SkillActionKind {
138 Install,
139 Import,
140 Update,
141 Remove,
142 Trust,
143 }
144
145 #[derive(Debug, Clone, PartialEq, Eq)]
146 pub enum SkillAuditWarning {
147 Message(String),
148 }
149
150 #[derive(Debug, Clone, PartialEq, Eq)]
151 pub struct AuditedSkill {
152 pub id: AuditedSkillId,
153 pub name: String,
154 pub description: Option<String>,
155 pub root: SkillRootDescriptor,
156 pub safe_display_path: String,
157 pub source_kind: SkillSourceKind,
158 pub parser: ParserState,
159 pub digest: DigestState,
160 pub provenance: ProvenanceState,
161 pub trust: TrustState,
162 pub readiness: ReadinessState,
163 pub precedence: PrecedenceState,
164 pub integrity: IntegrityState,
165 pub available_actions: Vec<SkillActionKind>,
166 pub warnings: Vec<SkillAuditWarning>,
167 /// Same canonical name + same digest as another copy.
168 pub exact_duplicate_of: Option<AuditedSkillId>,
169 /// Same canonical name + different digest.
170 pub conflicts_with: Vec<AuditedSkillId>,
171 /// External copy with no owned same-name skill — import candidate.
172 pub import_candidate: bool,
173 /// Package path left the declared skill root (symlink escape, etc.).
174 pub path_unsafe: bool,
175 }
176
177 #[derive(Debug, Clone, PartialEq, Eq)]
178 pub struct SkillAuditSnapshot {
179 pub scan_mode: SkillAuditMode,
180 pub roots: Vec<SkillRootDescriptor>,
181 pub skills: Vec<AuditedSkill>,
182 pub generated_at: SystemTime,
183 }
184
185 /// Optional readiness cache from Issue #4407. Missing → [`ReadinessState::Unknown`].
186 pub trait SkillReadinessProvider {
187 fn readiness_for(&self, skill: &AuditedSkillId) -> Option<ReadinessState>;
188 }
189
190 /// Scan skill roots into a full, unmerged inventory.
191 #[cfg(test)]
192 #[must_use]
193 pub fn scan(
194 workspace: &Path,
195 home: Option<&Path>,
196 mode: SkillAuditMode,
197 readiness: Option<&dyn SkillReadinessProvider>,
198 ) -> SkillAuditSnapshot {
199 scan_with_configured(workspace, home, None, mode, readiness)
200 }
201
202 /// Scan skill roots with an optional configured `skills_dir`.
203 #[must_use]
204 pub fn scan_with_configured(
205 workspace: &Path,
206 home: Option<&Path>,
207 configured_skills_dir: Option<&Path>,
208 mode: SkillAuditMode,
209 readiness: Option<&dyn SkillReadinessProvider>,
210 ) -> SkillAuditSnapshot {
211 let catalog = SkillRootCatalog::build(workspace, home, configured_skills_dir);
212 let root_refs: Vec<SkillRootDescriptor> = match mode {
213 SkillAuditMode::OwnedOnly => catalog
214 .audit_owned_directories()
215 .into_iter()
216 .cloned()
217 .collect(),
218 SkillAuditMode::Compatible => catalog
219 .audit_compatible_directories()
220 .into_iter()
221 .cloned()
222 .collect(),
223 };
224
225 let mut skills = Vec::new();
226 for root in &root_refs {
227 skills.extend(scan_root(root, workspace, home));
228 }
229
230 classify_cross_root(&mut skills);
231 for skill in &mut skills {
232 skill.readiness = readiness
233 .and_then(|p| p.readiness_for(&skill.id))
234 .unwrap_or(ReadinessState::Unknown);
235 skill.available_actions = action_policy(skill);
236 }
237
238 SkillAuditSnapshot {
239 scan_mode: mode,
240 roots: root_refs,
241 skills,
242 generated_at: SystemTime::now(),
243 }
244 }
245
246 /// Expand an owned-only inventory to compatible roots without re-reading the
247 /// unchanged owned packages.
248 ///
249 /// The manager uses this for its interactive scan-mode toggle. Package audits
250 /// include bounded content hashing, so re-auditing every bundled owned skill
251 /// can make a simple keypress appear lost on a cold filesystem. Reusing rows by
252 /// root keeps the result ordered by catalog precedence while newly eligible
253 /// external roots are still read from disk.
254 #[must_use]
255 pub fn expand_owned_scan_to_compatible(
256 workspace: &Path,
257 home: Option<&Path>,
258 configured_skills_dir: Option<&Path>,
259 owned_skills: &[AuditedSkill],
260 readiness: Option<&dyn SkillReadinessProvider>,
261 ) -> SkillAuditSnapshot {
262 let catalog = SkillRootCatalog::build(workspace, home, configured_skills_dir);
263 let root_refs: Vec<SkillRootDescriptor> = catalog
264 .audit_compatible_directories()
265 .into_iter()
266 .cloned()
267 .collect();
268 let reusable_root_ids: HashSet<SkillRootId> = owned_skills
269 .iter()
270 .map(|skill| skill.id.root_id.clone())
271 .collect();
272
273 let mut skills = Vec::new();
274 for root in &root_refs {
275 if reusable_root_ids.contains(&root.id) {
276 skills.extend(
277 owned_skills
278 .iter()
279 .filter(|skill| skill.id.root_id == root.id)
280 .cloned(),
281 );
282 } else {
283 skills.extend(scan_root(root, workspace, home));
284 }
285 }
286
287 // The owned rows carried their previous cross-root result. Recompute it
288 // against the expanded inventory so precedence/conflict/import actions are
289 // exactly the same as a fresh compatible scan.
290 for skill in &mut skills {
291 skill.precedence = if skill.root.active_for_runtime {
292 PrecedenceState::Unknown
293 } else {
294 PrecedenceState::InactiveSource
295 };
296 skill.exact_duplicate_of = None;
297 skill.conflicts_with.clear();
298 skill.import_candidate = false;
299 skill.available_actions.clear();
300 }
301 classify_cross_root(&mut skills);
302 for skill in &mut skills {
303 skill.readiness = readiness
304 .and_then(|provider| provider.readiness_for(&skill.id))
305 .unwrap_or(ReadinessState::Unknown);
306 skill.available_actions = action_policy(skill);
307 }
308
309 SkillAuditSnapshot {
310 scan_mode: SkillAuditMode::Compatible,
311 roots: root_refs,
312 skills,
313 generated_at: SystemTime::now(),
314 }
315 }
316
317 /// Compute available mutations for one audited row (UI and controller share this).
318 #[must_use]
319 pub fn action_policy(skill: &AuditedSkill) -> Vec<SkillActionKind> {
320 if skill.path_unsafe {
321 return Vec::new();
322 }
323
324 match skill.source_kind {
325 SkillSourceKind::CodeWhaleManaged => {
326 let mut actions = Vec::new();
327 if matches!(skill.parser, ParserState::Valid | ParserState::Warning(_))
328 && !matches!(skill.integrity, IntegrityState::BrokenManagedInstall)
329 {
330 actions.push(SkillActionKind::Update);
331 }
332 actions.push(SkillActionKind::Remove);
333 if matches!(
334 skill.trust,
335 TrustState::Untrusted | TrustState::TrustStale | TrustState::LegacyAdvisory
336 ) && matches!(skill.digest, DigestState::Known(_))
337 && matches!(skill.parser, ParserState::Valid | ParserState::Warning(_))
338 {
339 actions.push(SkillActionKind::Trust);
340 }
341 actions
342 }
343 SkillSourceKind::CodeWhaleManual => Vec::new(),
344 SkillSourceKind::CompatibleExternal => {
345 // Import is offered for fresh candidates and for same-name owned
346 // peers (exact duplicate → AlreadyPresent, conflict → replace confirm).
347 // The mutation controller remains the authority on scope/conflict policy.
348 let importable = matches!(skill.parser, ParserState::Valid | ParserState::Warning(_))
349 && matches!(skill.digest, DigestState::Known(_))
350 && (skill.import_candidate
351 || skill.exact_duplicate_of.is_some()
352 || !skill.conflicts_with.is_empty());
353 if importable {
354 vec![SkillActionKind::Import]
355 } else {
356 Vec::new()
357 }
358 }
359 SkillSourceKind::BuiltIn
360 | SkillSourceKind::ReviewedPluginSnapshot
361 | SkillSourceKind::RegistryCache => Vec::new(),
362 }
363 }
364
365 // ── per-root scan ────────────────────────────────────────────────────────────
366
367 fn scan_root(
368 root: &SkillRootDescriptor,
369 workspace: &Path,
370 home: Option<&Path>,
371 ) -> Vec<AuditedSkill> {
372 let mut out = Vec::new();
373 let Ok(canonical_root) = fs::canonicalize(&root.path) else {
374 return out;
375 };
376 let mut visited = HashSet::new();
377 let mut packages = Vec::new();
378 find_skill_packages(&root.path, &canonical_root, 0, &mut visited, &mut packages);
379
380 for package_dir in packages {
381 out.push(audit_package(
382 root,
383 &package_dir,
384 &canonical_root,
385 workspace,
386 home,
387 ));
388 }
389 out
390 }
391
392 fn find_skill_packages(
393 dir: &Path,
394 canonical_root: &Path,
395 depth: usize,
396 visited: &mut HashSet<PathBuf>,
397 out: &mut Vec<PathBuf>,
398 ) {
399 if depth > AUDIT_MAX_DEPTH {
400 return;
401 }
402 let Ok(meta) = fs::symlink_metadata(dir) else {
403 return;
404 };
405 if meta.file_type().is_symlink() {
406 let Ok(canonical) = fs::canonicalize(dir) else {
407 return;
408 };
409 if !canonical.starts_with(canonical_root) || !canonical.is_dir() {
410 return;
411 }
412 if !visited.insert(canonical) {
413 return;
414 }
415 } else if meta.is_dir() {
416 let Ok(canonical) = fs::canonicalize(dir) else {
417 return;
418 };
419 if !visited.insert(canonical) {
420 return;
421 }
422 } else {
423 return;
424 }
425
426 let skill_md = dir.join("SKILL.md");
427 if skill_md.is_file() || fs::symlink_metadata(&skill_md).is_ok() {
428 out.push(dir.to_path_buf());
429 return; // do not descend into a skill package
430 }
431
432 let Ok(entries) = fs::read_dir(dir) else {
433 return;
434 };
435 for entry in entries.flatten() {
436 let path = entry.path();
437 if path
438 .file_name()
439 .and_then(|s| s.to_str())
440 .is_some_and(|name| name.starts_with('.'))
441 {
442 continue;
443 }
444 let Ok(meta) = fs::symlink_metadata(&path) else {
445 continue;
446 };
447 if meta.is_dir() || meta.file_type().is_symlink() {
448 find_skill_packages(&path, canonical_root, depth + 1, visited, out);
449 }
450 }
451 }
452
453 fn audit_package(
454 root: &SkillRootDescriptor,
455 package_dir: &Path,
456 canonical_root: &Path,
457 workspace: &Path,
458 home: Option<&Path>,
459 ) -> AuditedSkill {
460 let relative_dir = package_dir
461 .strip_prefix(&root.path)
462 .map(Path::to_path_buf)
463 .unwrap_or_else(|_| {
464 package_dir
465 .file_name()
466 .map(PathBuf::from)
467 .unwrap_or_else(|| PathBuf::from("."))
468 });
469
470 let mut warnings = Vec::new();
471 let skill_md = package_dir.join("SKILL.md");
472 let (parser, name, description, skill_md_content) = parse_skill_md_bounded(&skill_md);
473
474 let package = analyze_package(package_dir, canonical_root);
475 let path_unsafe = package.path_unsafe;
476 if path_unsafe {
477 warnings.push(SkillAuditWarning::Message(
478 "package contains a symlink that escapes the skill root or cycles".into(),
479 ));
480 }
481 for w in package.warnings {
482 warnings.push(SkillAuditWarning::Message(w));
483 }
484
485 let canonical_name = name
486 .as_deref()
487 .map(normalize_skill_name_for_lookup)
488 .unwrap_or_else(|| {
489 normalize_skill_name_for_lookup(
490 &relative_dir
491 .file_name()
492 .map(|s| s.to_string_lossy().into_owned())
493 .unwrap_or_else(|| "skill".into()),
494 )
495 });
496
497 let marker = read_installed_from(package_dir);
498 let trust = read_trust_state(package_dir, &package.digest);
499 let (source_kind, provenance, integrity) = classify_source(
500 root,
501 &canonical_name,
502 skill_md_content.as_deref(),
503 &marker,
504 &package.digest,
505 path_unsafe,
506 );
507
508 let display = format!(
509 "{}/{}",
510 safe_display_path(&root.path, Some(workspace), home),
511 relative_dir.display()
512 )
513 .replace('\\', "/");
514
515 AuditedSkill {
516 id: AuditedSkillId {
517 root_id: root.id.clone(),
518 relative_dir,
519 canonical_name: canonical_name.clone(),
520 },
521 name: canonical_name,
522 description,
523 root: root.clone(),
524 safe_display_path: display,
525 source_kind,
526 parser,
527 digest: package.digest,
528 provenance,
529 trust,
530 readiness: ReadinessState::Unknown,
531 precedence: if root.active_for_runtime {
532 PrecedenceState::Unknown // filled in classify_cross_root
533 } else {
534 PrecedenceState::InactiveSource
535 },
536 integrity,
537 available_actions: Vec::new(),
538 warnings,
539 exact_duplicate_of: None,
540 conflicts_with: Vec::new(),
541 import_candidate: false,
542 path_unsafe,
543 }
544 }
545
546 fn parse_skill_md_bounded(
547 path: &Path,
548 ) -> (ParserState, Option<String>, Option<String>, Option<String>) {
549 let meta = match fs::symlink_metadata(path) {
550 Ok(m) => m,
551 Err(err) => {
552 return (
553 ParserState::Broken(format!("cannot stat SKILL.md: {err}")),
554 None,
555 None,
556 None,
557 );
558 }
559 };
560 if meta.file_type().is_symlink() {
561 return (
562 ParserState::Broken("SKILL.md is a symlink".into()),
563 None,
564 None,
565 None,
566 );
567 }
568 if meta.len() > AUDIT_MAX_SKILL_MD_BYTES {
569 return (ParserState::Oversized, None, None, None);
570 }
571
572 let file = match File::open(path) {
573 Ok(f) => f,
574 Err(err) => {
575 return (
576 ParserState::Broken(format!("cannot open SKILL.md: {err}")),
577 None,
578 None,
579 None,
580 );
581 }
582 };
583 let mut limited: Take<File> = file.take(AUDIT_MAX_SKILL_MD_BYTES + 1);
584 let mut buf = Vec::new();
585 if let Err(err) = limited.read_to_end(&mut buf) {
586 return (
587 ParserState::Broken(format!("cannot read SKILL.md: {err}")),
588 None,
589 None,
590 None,
591 );
592 }
593 if buf.len() as u64 > AUDIT_MAX_SKILL_MD_BYTES {
594 return (ParserState::Oversized, None, None, None);
595 }
596 let content = match String::from_utf8(buf) {
597 Ok(s) => s,
598 Err(_) => {
599 return (
600 ParserState::Broken("SKILL.md is not valid UTF-8".into()),
601 None,
602 None,
603 None,
604 );
605 }
606 };
607
608 match SkillRegistry::parse_skill(path, &content) {
609 Ok(skill) => {
610 let desc = if skill.description.is_empty() {
611 None
612 } else {
613 Some(truncate_desc(&skill.description))
614 };
615 let mut warnings = Vec::new();
616 if skill.description.is_empty() {
617 warnings.push("missing description".into());
618 }
619 let parser = if warnings.is_empty() {
620 ParserState::Valid
621 } else {
622 ParserState::Warning(warnings)
623 };
624 (parser, Some(skill.name), desc, Some(content))
625 }
626 Err(reason) => (ParserState::Broken(reason), None, None, Some(content)),
627 }
628 }
629
630 fn truncate_desc(s: &str) -> String {
631 const MAX: usize = 280;
632 let count = s.chars().count();
633 if count <= MAX {
634 s.to_string()
635 } else {
636 let truncated: String = s.chars().take(MAX.saturating_sub(1)).collect();
637 format!("{truncated}…")
638 }
639 }
640
641 struct PackageAnalysis {
642 digest: DigestState,
643 path_unsafe: bool,
644 warnings: Vec<String>,
645 }
646
647 /// Test-only digest helper. Prod paths (audit, mutation) call
648 /// `package_digest::compute_package_digest` directly.
649 #[cfg(test)]
650 pub fn compute_package_digest(package_dir: &Path) -> Result<String, DigestUnknownReason> {
651 package_digest::compute_package_digest(package_dir).map_err(digest_error_to_unknown)
652 }
653
654 fn digest_error_to_unknown(err: PackageDigestError) -> DigestUnknownReason {
655 match err {
656 PackageDigestError::Unreadable => DigestUnknownReason::Unreadable,
657 PackageDigestError::SymlinkPresent => DigestUnknownReason::SymlinkPresent,
658 PackageDigestError::EscapedRoot => DigestUnknownReason::EscapedRoot,
659 PackageDigestError::Cycle => DigestUnknownReason::Cycle,
660 PackageDigestError::Oversized => DigestUnknownReason::Oversized,
661 PackageDigestError::TooManyFiles => DigestUnknownReason::TooManyFiles,
662 PackageDigestError::TooDeep => DigestUnknownReason::TooDeep,
663 }
664 }
665
666 fn analyze_package(package_dir: &Path, _canonical_root: &Path) -> PackageAnalysis {
667 match package_digest::compute_package_digest(package_dir) {
668 Ok(digest) => PackageAnalysis {
669 digest: DigestState::Known(digest),
670 path_unsafe: false,
671 warnings: Vec::new(),
672 },
673 Err(err) => {
674 let reason = digest_error_to_unknown(err.clone());
675 let path_unsafe = matches!(
676 err,
677 PackageDigestError::SymlinkPresent
678 | PackageDigestError::EscapedRoot
679 | PackageDigestError::Cycle
680 );
681 PackageAnalysis {
682 digest: DigestState::Unknown(reason),
683 path_unsafe,
684 warnings: vec![err.to_string()],
685 }
686 }
687 }
688 }
689
690 // ── markers ──────────────────────────────────────────────────────────────────
691
692 #[derive(Debug, Clone, Deserialize)]
693 struct InstalledFromFile {
694 #[serde(default)]
695 schema_version: Option<u32>,
696 #[serde(default)]
697 spec: Option<String>,
698 #[serde(default)]
699 url: Option<String>,
700 #[serde(default)]
701 content_digest: Option<String>,
702 }
703
704 #[derive(Debug, Clone)]
705 enum MarkerParse {
706 Absent,
707 V1(InstalledFromFile),
708 V2(InstalledFromFile),
709 // Reason kept for Debug + future warning surfacing; matches bind `_`.
710 Broken(#[allow(dead_code)] String),
711 }
712
713 fn read_installed_from(package_dir: &Path) -> MarkerParse {
714 let path = package_dir.join(INSTALLED_FROM_MARKER);
715 let meta = match fs::symlink_metadata(&path) {
716 Ok(m) => m,
717 Err(_) => return MarkerParse::Absent,
718 };
719 if meta.file_type().is_symlink() {
720 return MarkerParse::Broken("symlink .installed-from".into());
721 }
722 if !meta.is_file() {
723 return MarkerParse::Broken(".installed-from is not a regular file".into());
724 }
725 let Ok(body) = fs::read_to_string(&path) else {
726 return MarkerParse::Broken("unreadable .installed-from".into());
727 };
728 let Ok(parsed) = serde_json::from_str::<InstalledFromFile>(&body) else {
729 return MarkerParse::Broken("malformed .installed-from".into());
730 };
731 match parsed.schema_version {
732 Some(v) if v >= 2 => MarkerParse::V2(parsed),
733 Some(_) | None => MarkerParse::V1(parsed),
734 }
735 }
736
737 #[derive(Debug, Deserialize)]
738 struct TrustFileV2 {
739 #[serde(default)]
740 schema_version: Option<u32>,
741 #[serde(default)]
742 content_digest: Option<String>,
743 }
744
745 fn read_trust_state(package_dir: &Path, digest: &DigestState) -> TrustState {
746 let path = package_dir.join(TRUSTED_MARKER);
747 let meta = match fs::symlink_metadata(&path) {
748 Ok(m) => m,
749 Err(_) => return TrustState::Untrusted,
750 };
751 if meta.file_type().is_symlink() {
752 // Do not follow symlink trust markers.
753 return TrustState::Unknown;
754 }
755 if !meta.is_file() {
756 return TrustState::Unknown;
757 }
758 let Ok(body) = fs::read_to_string(&path) else {
759 return TrustState::Unknown;
760 };
761 if let Ok(parsed) = serde_json::from_str::<TrustFileV2>(&body)
762 && parsed.schema_version == Some(2)
763 {
764 let Some(trusted_digest) = parsed.content_digest else {
765 return TrustState::Unknown;
766 };
767 return match digest {
768 DigestState::Known(current) if current == &trusted_digest => {
769 TrustState::TrustedForDigest(trusted_digest)
770 }
771 DigestState::Known(_) => TrustState::TrustStale,
772 DigestState::Unknown(_) => TrustState::Unknown,
773 };
774 }
775 TrustState::LegacyAdvisory
776 }
777
778 fn sanitize_url_for_display(url: &str) -> String {
779 // Strip userinfo, query, and fragment before UI / receipts.
780 let without_fragment = url.split('#').next().unwrap_or(url);
781 let without_query = without_fragment
782 .split('?')
783 .next()
784 .unwrap_or(without_fragment);
785 if let Some(scheme_end) = without_query.find("://") {
786 let scheme = &without_query[..scheme_end];
787 let rest = &without_query[scheme_end + 3..];
788 if let Some(at) = rest.find('@') {
789 return format!("{scheme}://{}", &rest[at + 1..]);
790 }
791 }
792 without_query.to_string()
793 }
794
795 fn classify_source(
796 root: &SkillRootDescriptor,
797 canonical_name: &str,
798 skill_md_content: Option<&str>,
799 marker: &MarkerParse,
800 digest: &DigestState,
801 _path_unsafe: bool,
802 ) -> (SkillSourceKind, ProvenanceState, IntegrityState) {
803 if matches!(root.kind, SkillRootKind::RegistryCache) {
804 return (
805 SkillSourceKind::RegistryCache,
806 ProvenanceState::Cache,
807 IntegrityState::Unknown,
808 );
809 }
810 if matches!(root.kind, SkillRootKind::ReviewedPluginSnapshot) {
811 return (
812 SkillSourceKind::ReviewedPluginSnapshot,
813 ProvenanceState::Plugin,
814 IntegrityState::Unknown,
815 );
816 }
817
818 if root.access != SkillRootAccess::WritableOwned {
819 return (
820 SkillSourceKind::CompatibleExternal,
821 ProvenanceState::External,
822 IntegrityState::Unknown,
823 );
824 }
825
826 // Managed markers win over bundled-name heuristics so a registry install
827 // that reuses a bundled command name (e.g. `pdf`) stays Update/Remove/Trust
828 // capable. Exact shipped body without a marker is still BuiltIn.
829 match marker {
830 MarkerParse::V1(_) | MarkerParse::V2(_) | MarkerParse::Broken(_) => {}
831 MarkerParse::Absent => {
832 if let Some(content) = skill_md_content
833 && is_exact_bundled_skill(canonical_name, content)
834 {
835 return (
836 SkillSourceKind::BuiltIn,
837 ProvenanceState::BuiltIn,
838 IntegrityState::Healthy,
839 );
840 }
841 }
842 }
843
844 match marker {
845 MarkerParse::Absent => (
846 SkillSourceKind::CodeWhaleManual,
847 ProvenanceState::Manual,
848 IntegrityState::Unknown,
849 ),
850 MarkerParse::Broken(_) => (
851 SkillSourceKind::CodeWhaleManaged,
852 ProvenanceState::Managed {
853 spec: None,
854 safe_url: None,
855 schema_version: None,
856 },
857 IntegrityState::BrokenManagedInstall,
858 ),
859 MarkerParse::V1(m) => (
860 SkillSourceKind::CodeWhaleManaged,
861 ProvenanceState::Managed {
862 spec: m.spec.clone(),
863 safe_url: m.url.as_deref().map(sanitize_url_for_display),
864 schema_version: m.schema_version.or(Some(1)),
865 },
866 IntegrityState::LegacyMetadataUnknown,
867 ),
868 MarkerParse::V2(m) => {
869 let integrity = match (&m.content_digest, digest) {
870 (Some(expected), DigestState::Known(actual)) if expected == actual => {
871 IntegrityState::Healthy
872 }
873 (Some(_), DigestState::Known(_)) => IntegrityState::LocalContentDrift,
874 (None, _) => IntegrityState::Unknown,
875 (_, DigestState::Unknown(_)) => IntegrityState::Unknown,
876 };
877 (
878 SkillSourceKind::CodeWhaleManaged,
879 ProvenanceState::Managed {
880 spec: m.spec.clone(),
881 safe_url: m.url.as_deref().map(sanitize_url_for_display),
882 schema_version: m.schema_version,
883 },
884 integrity,
885 )
886 }
887 }
888 }
889
890 // ── cross-root classification ────────────────────────────────────────────────
891
892 fn classify_cross_root(skills: &mut [AuditedSkill]) {
893 // Group by canonical name preserving first-seen order (catalog precedence).
894 let mut by_name: HashMap<String, Vec<usize>> = HashMap::new();
895 for (idx, skill) in skills.iter().enumerate() {
896 by_name
897 .entry(skill.id.canonical_name.clone())
898 .or_default()
899 .push(idx);
900 }
901
902 let owned_names: HashSet<String> = skills
903 .iter()
904 .filter(|s| s.root.is_writable_owned())
905 .map(|s| s.id.canonical_name.clone())
906 .collect();
907
908 for indices in by_name.values() {
909 if indices.is_empty() {
910 continue;
911 }
912
913 // Runtime-active winners: among copies whose root is active_for_runtime,
914 // the earliest in catalog order wins. Audit-only roots stay InactiveSource.
915 let runtime_indices: Vec<usize> = indices
916 .iter()
917 .copied()
918 .filter(|&i| skills[i].root.active_for_runtime)
919 .collect();
920 // Already in scan order which follows catalog precedence.
921 if let Some(&winner) = runtime_indices.first() {
922 let winner_id = skills[winner].id.clone();
923 for &idx in &runtime_indices {
924 if idx == winner {
925 if !matches!(skills[idx].precedence, PrecedenceState::InactiveSource) {
926 skills[idx].precedence = PrecedenceState::Active;
927 }
928 } else {
929 skills[idx].precedence = PrecedenceState::ShadowedBy(winner_id.clone());
930 }
931 }
932 }
933
934 // Duplicate / conflict among all copies (including inactive).
935 let digests: Vec<(usize, Option<String>)> = indices
936 .iter()
937 .map(|&i| {
938 let d = match &skills[i].digest {
939 DigestState::Known(s) => Some(s.clone()),
940 DigestState::Unknown(_) => None,
941 };
942 (i, d)
943 })
944 .collect();
945
946 for &(i, ref di) in &digests {
947 for &(j, ref dj) in &digests {
948 if i >= j {
949 continue;
950 }
951 match (di, dj) {
952 (Some(a), Some(b)) if a == b => {
953 let other = skills[j].id.clone();
954 if skills[i].exact_duplicate_of.is_none() {
955 skills[i].exact_duplicate_of = Some(other);
956 } else {
957 let other = skills[i].id.clone();
958 if skills[j].exact_duplicate_of.is_none() {
959 skills[j].exact_duplicate_of = Some(other);
960 }
961 }
962 }
963 (Some(_), Some(_)) => {
964 let id_j = skills[j].id.clone();
965 let id_i = skills[i].id.clone();
966 skills[i].conflicts_with.push(id_j);
967 skills[j].conflicts_with.push(id_i);
968 }
969 _ => {}
970 }
971 }
972 }
973 }
974
975 for skill in skills.iter_mut() {
976 if skill.source_kind == SkillSourceKind::CompatibleExternal
977 && !owned_names.contains(&skill.id.canonical_name)
978 && matches!(skill.parser, ParserState::Valid | ParserState::Warning(_))
979 && matches!(skill.digest, DigestState::Known(_))
980 && !skill.path_unsafe
981 {
982 skill.import_candidate = true;
983 }
984 }
985 }
986
987 #[cfg(test)]
988 mod tests {
989 use super::*;
990 use tempfile::TempDir;
991
992 fn write_skill(dir: &Path, name: &str, description: &str, body: &str) {
993 let skill_dir = dir.join(name);
994 fs::create_dir_all(&skill_dir).unwrap();
995 fs::write(
996 skill_dir.join("SKILL.md"),
997 format!("---\nname: {name}\ndescription: {description}\n---\n{body}\n"),
998 )
999 .unwrap();
1000 }
1001
1002 #[test]
1003 fn owned_only_skips_compatible_and_codex() {
1004 let tmp = TempDir::new().unwrap();
1005 let workspace = tmp.path().join("ws");
1006 let home = tmp.path().join("home");
1007 write_skill(
1008 &workspace.join(".codewhale").join("skills"),
1009 "owned",
1010 "owned skill",
1011 "body",
1012 );
1013 write_skill(
1014 &workspace.join(".claude").join("skills"),
1015 "claude",
1016 "claude skill",
1017 "body",
1018 );
1019 write_skill(
1020 &workspace.join(".codex").join("skills"),
1021 "codex",
1022 "codex skill",
1023 "body",
1024 );
1025
1026 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1027 let names: Vec<_> = snap.skills.iter().map(|s| s.name.as_str()).collect();
1028 assert_eq!(names, vec!["owned"]);
1029 assert!(!snap.roots.iter().any(|r| matches!(
1030 r.kind,
1031 SkillRootKind::CompatibleProject(_) | SkillRootKind::CompatibleGlobal(_)
1032 )));
1033 }
1034
1035 #[test]
1036 fn compatible_includes_codex_without_activating_runtime_precedence() {
1037 let tmp = TempDir::new().unwrap();
1038 let workspace = tmp.path().join("ws");
1039 let home = tmp.path().join("home");
1040 write_skill(
1041 &workspace.join(".codewhale").join("skills"),
1042 "shared",
1043 "owned",
1044 "owned-body",
1045 );
1046 write_skill(
1047 &workspace.join(".codex").join("skills"),
1048 "shared",
1049 "codex",
1050 "codex-body",
1051 );
1052
1053 let snap = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None);
1054 assert_eq!(snap.skills.len(), 2);
1055 let codex = snap
1056 .skills
1057 .iter()
1058 .find(|s| {
1059 matches!(
1060 s.root.kind,
1061 SkillRootKind::CompatibleProject(super::super::roots::CompatibleHarness::Codex)
1062 )
1063 })
1064 .expect("codex copy");
1065 assert_eq!(codex.precedence, PrecedenceState::InactiveSource);
1066 assert!(!codex.root.active_for_runtime);
1067
1068 let owned = snap
1069 .skills
1070 .iter()
1071 .find(|s| s.root.kind == SkillRootKind::CodeWhaleProject)
1072 .expect("owned");
1073 assert_eq!(owned.precedence, PrecedenceState::Active);
1074 }
1075
1076 #[test]
1077 fn expanding_owned_scan_matches_fresh_compatible_scan() {
1078 let tmp = TempDir::new().unwrap();
1079 let workspace = tmp.path().join("ws");
1080 let home = tmp.path().join("home");
1081 write_skill(
1082 &workspace.join(".codewhale").join("skills"),
1083 "shared",
1084 "owned",
1085 "owned-body",
1086 );
1087 write_skill(
1088 &workspace.join(".agents").join("skills"),
1089 "shared",
1090 "external conflict",
1091 "external-body",
1092 );
1093 write_skill(
1094 &workspace.join(".codex").join("skills"),
1095 "candidate",
1096 "import candidate",
1097 "candidate-body",
1098 );
1099
1100 let owned = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1101 let expanded =
1102 expand_owned_scan_to_compatible(&workspace, Some(&home), None, &owned.skills, None);
1103 let fresh = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None);
1104
1105 assert_eq!(expanded.scan_mode, SkillAuditMode::Compatible);
1106 assert_eq!(expanded.roots, fresh.roots);
1107 assert_eq!(expanded.skills, fresh.skills);
1108 }
1109
1110 #[test]
1111 fn detects_shadow_duplicate_and_conflict() {
1112 let tmp = TempDir::new().unwrap();
1113 let workspace = tmp.path().join("ws");
1114 let home = tmp.path().join("home");
1115
1116 // Identical package content → exact duplicate (after shadowing).
1117 let identical = "---\nname: shared\ndescription: same\n---\nbody\n";
1118 fs::create_dir_all(workspace.join(".agents").join("skills").join("shared")).unwrap();
1119 fs::write(
1120 workspace
1121 .join(".agents")
1122 .join("skills")
1123 .join("shared")
1124 .join("SKILL.md"),
1125 identical,
1126 )
1127 .unwrap();
1128 fs::create_dir_all(workspace.join(".claude").join("skills").join("shared")).unwrap();
1129 fs::write(
1130 workspace
1131 .join(".claude")
1132 .join("skills")
1133 .join("shared")
1134 .join("SKILL.md"),
1135 identical,
1136 )
1137 .unwrap();
1138 // Different content → conflict with the active copy.
1139 write_skill(
1140 &workspace.join(".cursor").join("skills"),
1141 "shared",
1142 "cursor conflict",
1143 "different-body",
1144 );
1145
1146 let snap = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None);
1147 let shared: Vec<_> = snap.skills.iter().filter(|s| s.name == "shared").collect();
1148 assert_eq!(shared.len(), 3);
1149 assert!(
1150 shared
1151 .iter()
1152 .any(|s| matches!(s.precedence, PrecedenceState::Active))
1153 );
1154 assert!(
1155 shared
1156 .iter()
1157 .any(|s| matches!(s.precedence, PrecedenceState::ShadowedBy(_)))
1158 );
1159 assert!(shared.iter().any(|s| s.exact_duplicate_of.is_some()));
1160 assert!(shared.iter().any(|s| !s.conflicts_with.is_empty()));
1161 }
1162
1163 #[test]
1164 fn external_without_owned_peer_is_import_candidate() {
1165 let tmp = TempDir::new().unwrap();
1166 let workspace = tmp.path().join("ws");
1167 let home = tmp.path().join("home");
1168 fs::create_dir_all(workspace.join(".codewhale").join("skills")).unwrap();
1169 write_skill(
1170 &workspace.join(".claude").join("skills"),
1171 "from-claude",
1172 "desc",
1173 "body",
1174 );
1175
1176 let snap = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None);
1177 let skill = snap
1178 .skills
1179 .iter()
1180 .find(|s| s.name == "from-claude")
1181 .expect("skill");
1182 assert!(skill.import_candidate);
1183 assert_eq!(skill.available_actions, vec![SkillActionKind::Import]);
1184 assert_eq!(skill.source_kind, SkillSourceKind::CompatibleExternal);
1185 }
1186
1187 #[test]
1188 fn external_conflicting_with_owned_still_offers_import() {
1189 let tmp = TempDir::new().unwrap();
1190 let workspace = tmp.path().join("ws");
1191 let home = tmp.path().join("home");
1192 write_skill(
1193 &workspace.join(".codewhale").join("skills"),
1194 "shared",
1195 "desc",
1196 "owned-body",
1197 );
1198 write_skill(
1199 &workspace.join(".claude").join("skills"),
1200 "shared",
1201 "desc",
1202 "external-body",
1203 );
1204
1205 let snap = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None);
1206 let external = snap
1207 .skills
1208 .iter()
1209 .find(|s| s.name == "shared" && s.source_kind == SkillSourceKind::CompatibleExternal)
1210 .expect("external");
1211 assert!(!external.import_candidate);
1212 assert!(!external.conflicts_with.is_empty());
1213 assert_eq!(external.available_actions, vec![SkillActionKind::Import]);
1214 }
1215
1216 #[test]
1217 fn v1_marker_is_legacy_integrity_and_managed_actions() {
1218 let tmp = TempDir::new().unwrap();
1219 let workspace = tmp.path().join("ws");
1220 let home = tmp.path().join("home");
1221 let root = workspace.join(".codewhale").join("skills");
1222 write_skill(&root, "managed", "desc", "body");
1223 fs::write(
1224 root.join("managed").join(INSTALLED_FROM_MARKER),
1225 r#"{"spec":"github:o/r","url":"https://user:pass@example.com/x?token=1#frag","checksum":"abc"}"#,
1226 )
1227 .unwrap();
1228
1229 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1230 let skill = &snap.skills[0];
1231 assert_eq!(skill.source_kind, SkillSourceKind::CodeWhaleManaged);
1232 assert_eq!(skill.integrity, IntegrityState::LegacyMetadataUnknown);
1233 assert!(skill.available_actions.contains(&SkillActionKind::Update));
1234 assert!(skill.available_actions.contains(&SkillActionKind::Remove));
1235 if let ProvenanceState::Managed { safe_url, .. } = &skill.provenance {
1236 let url = safe_url.as_deref().unwrap();
1237 assert!(!url.contains("user:pass"));
1238 assert!(!url.contains("token"));
1239 assert!(!url.contains("frag"));
1240 } else {
1241 panic!("expected managed provenance");
1242 }
1243 }
1244
1245 #[test]
1246 fn v2_marker_detects_healthy_and_drift() {
1247 let tmp = TempDir::new().unwrap();
1248 let workspace = tmp.path().join("ws");
1249 let home = tmp.path().join("home");
1250 let root = workspace.join(".codewhale").join("skills");
1251 write_skill(&root, "managed", "desc", "body");
1252
1253 // First scan to learn digest, then write matching v2 marker.
1254 let preliminary = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1255 let DigestState::Known(digest) = &preliminary.skills[0].digest else {
1256 panic!("expected known digest");
1257 };
1258 fs::write(
1259 root.join("managed").join(INSTALLED_FROM_MARKER),
1260 format!(r#"{{"schema_version":2,"spec":"github:o/r","content_digest":"{digest}"}}"#),
1261 )
1262 .unwrap();
1263 let healthy = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1264 assert_eq!(healthy.skills[0].integrity, IntegrityState::Healthy);
1265
1266 fs::write(
1267 root.join("managed").join(INSTALLED_FROM_MARKER),
1268 r#"{"schema_version":2,"spec":"github:o/r","content_digest":"deadbeef"}"#,
1269 )
1270 .unwrap();
1271 let drift = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1272 assert_eq!(drift.skills[0].integrity, IntegrityState::LocalContentDrift);
1273 }
1274
1275 #[test]
1276 fn legacy_trust_and_digest_bound_trust() {
1277 let tmp = TempDir::new().unwrap();
1278 let workspace = tmp.path().join("ws");
1279 let home = tmp.path().join("home");
1280 let root = workspace.join(".codewhale").join("skills");
1281 write_skill(&root, "managed", "desc", "body");
1282 fs::write(
1283 root.join("managed").join(INSTALLED_FROM_MARKER),
1284 r#"{"spec":"github:o/r","checksum":"x"}"#,
1285 )
1286 .unwrap();
1287 fs::write(root.join("managed").join(TRUSTED_MARKER), "trusted\n").unwrap();
1288
1289 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1290 assert_eq!(snap.skills[0].trust, TrustState::LegacyAdvisory);
1291
1292 let DigestState::Known(digest) = &snap.skills[0].digest else {
1293 panic!("digest");
1294 };
1295 fs::write(
1296 root.join("managed").join(TRUSTED_MARKER),
1297 format!(r#"{{"schema_version":2,"content_digest":"{digest}"}}"#),
1298 )
1299 .unwrap();
1300 let trusted = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1301 assert!(matches!(
1302 trusted.skills[0].trust,
1303 TrustState::TrustedForDigest(_)
1304 ));
1305
1306 fs::write(
1307 root.join("managed").join(TRUSTED_MARKER),
1308 r#"{"schema_version":2,"content_digest":"stale"}"#,
1309 )
1310 .unwrap();
1311 let stale = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1312 assert_eq!(stale.skills[0].trust, TrustState::TrustStale);
1313 }
1314
1315 #[test]
1316 fn oversized_skill_md_is_fail_closed() {
1317 let tmp = TempDir::new().unwrap();
1318 let workspace = tmp.path().join("ws");
1319 let home = tmp.path().join("home");
1320 let skill_dir = workspace.join(".codewhale").join("skills").join("big");
1321 fs::create_dir_all(&skill_dir).unwrap();
1322 let huge = format!(
1323 "---\nname: big\ndescription: x\n---\n{}",
1324 "x".repeat(AUDIT_MAX_SKILL_MD_BYTES as usize + 64)
1325 );
1326 fs::write(skill_dir.join("SKILL.md"), huge).unwrap();
1327
1328 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1329 assert_eq!(snap.skills[0].parser, ParserState::Oversized);
1330 assert!(snap.skills[0].available_actions.is_empty());
1331 }
1332
1333 #[test]
1334 fn readiness_missing_stays_unknown() {
1335 let tmp = TempDir::new().unwrap();
1336 let workspace = tmp.path().join("ws");
1337 let home = tmp.path().join("home");
1338 write_skill(&workspace.join(".codewhale").join("skills"), "a", "d", "b");
1339 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1340 assert_eq!(snap.skills[0].readiness, ReadinessState::Unknown);
1341 }
1342
1343 #[test]
1344 fn bundled_name_alone_is_not_built_in() {
1345 let tmp = TempDir::new().unwrap();
1346 let workspace = tmp.path().join("ws");
1347 let home = tmp.path().join("home");
1348 // `pdf` is a bundled name, but custom body must not classify as BuiltIn.
1349 write_skill(
1350 &workspace.join(".codewhale").join("skills"),
1351 "pdf",
1352 "user override",
1353 "not-the-bundled-body",
1354 );
1355 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1356 assert_eq!(snap.skills[0].source_kind, SkillSourceKind::CodeWhaleManual);
1357 assert!(snap.skills[0].available_actions.is_empty());
1358 }
1359
1360 #[test]
1361 fn managed_marker_wins_over_bundled_name() {
1362 let tmp = TempDir::new().unwrap();
1363 let workspace = tmp.path().join("ws");
1364 let home = tmp.path().join("home");
1365 let root = workspace.join(".codewhale").join("skills");
1366 // Bundled command name + different body + install marker → managed.
1367 write_skill(&root, "pdf", "registry pdf", "community-body");
1368 fs::write(
1369 root.join("pdf").join(INSTALLED_FROM_MARKER),
1370 r#"{"spec":"github:o/pdf-skill","checksum":"abc"}"#,
1371 )
1372 .unwrap();
1373
1374 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1375 assert_eq!(
1376 snap.skills[0].source_kind,
1377 SkillSourceKind::CodeWhaleManaged
1378 );
1379 assert!(
1380 snap.skills[0]
1381 .available_actions
1382 .contains(&SkillActionKind::Update)
1383 );
1384 assert!(
1385 snap.skills[0]
1386 .available_actions
1387 .contains(&SkillActionKind::Remove)
1388 );
1389 assert!(
1390 snap.skills[0]
1391 .available_actions
1392 .contains(&SkillActionKind::Trust)
1393 );
1394 }
1395
1396 #[test]
1397 fn exact_bundled_content_is_built_in() {
1398 let tmp = TempDir::new().unwrap();
1399 let workspace = tmp.path().join("ws");
1400 let home = tmp.path().join("home");
1401 let root = workspace.join(".codewhale").join("skills");
1402 fs::create_dir_all(root.join("pdf")).unwrap();
1403 fs::write(
1404 root.join("pdf").join("SKILL.md"),
1405 include_str!("../../assets/skills/pdf/SKILL.md"),
1406 )
1407 .unwrap();
1408
1409 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1410 assert_eq!(snap.skills[0].source_kind, SkillSourceKind::BuiltIn);
1411 assert!(snap.skills[0].available_actions.is_empty());
1412 }
1413
1414 #[cfg(unix)]
1415 #[test]
1416 fn symlink_installed_from_marker_is_fail_closed() {
1417 let tmp = TempDir::new().unwrap();
1418 let workspace = tmp.path().join("ws");
1419 let home = tmp.path().join("home");
1420 let root = workspace.join(".codewhale").join("skills");
1421 write_skill(&root, "managed", "desc", "body");
1422 let outside = tmp.path().join("outside-marker.json");
1423 fs::write(&outside, r#"{"spec":"github:o/r","checksum":"x"}"#).unwrap();
1424 std::os::unix::fs::symlink(&outside, root.join("managed").join(INSTALLED_FROM_MARKER))
1425 .unwrap();
1426
1427 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1428 assert!(snap.skills[0].path_unsafe);
1429 assert!(matches!(
1430 snap.skills[0].digest,
1431 DigestState::Unknown(DigestUnknownReason::SymlinkPresent)
1432 ));
1433 assert_eq!(
1434 snap.skills[0].integrity,
1435 IntegrityState::BrokenManagedInstall
1436 );
1437 assert!(snap.skills[0].available_actions.is_empty());
1438 }
1439
1440 #[test]
1441 fn sanitize_url_strips_secrets() {
1442 assert_eq!(
1443 sanitize_url_for_display("https://user:pw@host/path?token=1#x"),
1444 "https://host/path"
1445 );
1446 }
1447
1448 struct AlwaysReady;
1449 impl SkillReadinessProvider for AlwaysReady {
1450 fn readiness_for(&self, _: &AuditedSkillId) -> Option<ReadinessState> {
1451 Some(ReadinessState::Ready)
1452 }
1453 }
1454
1455 #[test]
1456 fn readiness_provider_is_consulted_when_present() {
1457 let tmp = TempDir::new().unwrap();
1458 let workspace = tmp.path().join("ws");
1459 let home = tmp.path().join("home");
1460 write_skill(&workspace.join(".codewhale").join("skills"), "a", "d", "b");
1461 let snap = scan(
1462 &workspace,
1463 Some(&home),
1464 SkillAuditMode::OwnedOnly,
1465 Some(&AlwaysReady),
1466 );
1467 assert_eq!(snap.skills[0].readiness, ReadinessState::Ready);
1468 }
1469 }
1470
1470 lines RUST