//! Canonical scanner pipeline. Per-document orchestration that other
//! modules rely on to preserve their guarantees.
//!
//! # Pipeline ordering (load-bearing)
//!
//! `scan_document_path` and the package-level entrypoints execute these
//! stages in this exact order:
//!
//! 1. Parse markdown → evaluate rules → scan supporting artifacts.
//! 2. Run artifact analysis on every artifact reachable from the entry.
//! 3. Derive taint findings from the artifact graph.
//! 4. **Apply inline suppressions** (`# skill-veil:ignore` markers).
//! 5. **Deduplicate** findings (`findings::deduplicate_findings`).
//! 6. **Apply policy filters** (baseline, waivers, overrides).
//! 7. Build the verdict (`PackageAssessmentPipeline`).
//! 8. Compute the CI gating signal (`should_fail`).
//!
//! # Ordering guarantees
//!
//! Each ordered pair below is load-bearing: reordering changes a guarantee
//! that other modules pin via regression tests. When you touch this file,
//! update the table and re-run
//! `cargo test -p skill-veil-core labeled_corpus_meets_phase1_baseline`
//! to confirm the corpus precision/recall stays within bounds.
//!
//! | Pair | Order | Why | Pinned by |
//! |-----------------------|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------|
//! | suppressions ↔ dedup | suppressions BEFORE dedup | Dedup merges on `(rule_id, category, matched_on, match_value, kind, scope, path)` and keeps the first non-`None` `line_number` it sees. If two emissions of the same rule arrive with different lines (one carrying a `// skill-veil:disable` source line, another path-less from artifact-graph taint), suppressing AFTER dedup would let the merged finding survive when its representative line is the non-suppressed copy. Suppressing first matches each emission against its own original line. | inline rationale at `scan_document_path` (see body of this file) |
//! | dedup ↔ policy | dedup BEFORE policy | Baselines and waivers fingerprint findings on the canonical (post-dedup) view. Filtering against the un-deduplicated stream lets repeated emissions bypass a single baseline entry. | `baseline_matches_finding_does_not_apply_paths_match_suffix` |
//! | policy ↔ verdict | policy BEFORE verdict | Calibration must see exactly the findings the user will see, so waived / overridden findings do not escalate severity in the verdict. | `labeled_corpus_meets_phase1_baseline` |
use crate::analyzer::SkillDocument;
use crate::artifact_graph::ArtifactGraph;
use crate::findings::{
deduplicate_findings, ArtifactKind, Finding, FindingSummary, MatchTarget, PackageVerdictReport,
};
use crate::policy::{
AppliedPolicyOverride, PolicyAudit, SuppressionSummary, POLICY_AUDIT_PRECEDENCE,
};
use crate::ports::{FileSystemProvider, MarkdownParser};
use crate::scanner::{ScanError, ScanResult, Scanner};
use crate::scanner_support::{
artifact_parse_error_finding, binary_disguise_finding, decode_warning_finding,
parse_warning_finding, read_text_file_lossy, structured_parse_warning,
};
use crate::services::file_discovery::FileDiscoveryService;
use crate::verdict::derive_package_verdict;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
pub(crate) fn scan_supporting_artifacts<F: FileSystemProvider, P: MarkdownParser>(
scanner: &Scanner<F, P>,
doc: &SkillDocument,
) -> Vec<Finding> {
let fs = scanner.file_discovery().fs_provider();
let mut findings = Vec::new();
let supporting_artifacts = collect_supporting_artifact_paths(scanner, doc);
for referenced_file in &supporting_artifacts {
// Existence and file-type checks both go through the
// `FileSystemProvider` port. Using `PathBuf::is_dir()` would consult
// `std::fs::metadata` directly (following symlinks — the port's
// `is_dir` uses `symlink_metadata` and rejects symlinks), opening
// both a TOCTOU window and a symlink-evasion path where a malicious
// package shipping `evil_dir -> /etc` would be silently skipped
// instead of surfacing as a read error. It also lets test doubles
// disagree with production behaviour.
//
// The `!fs.is_file()` guard rejects symlinks (including
// symlinks-to-directories), FIFOs, and device files — all of which
// would cause `read_file_bytes` to hang (FIFO), produce unbounded
// reads (devices), or spurious parse errors (symlink-to-directory).
if !fs.exists(referenced_file) || !fs.is_file(referenced_file) {
continue;
}
findings.extend(analyze_referenced_artifact(scanner, referenced_file));
}
findings
}
/// Per-artifact analysis: parse the file, then run binary-disguise checks,
/// engine rule evaluation, decode/parse warnings, and artifact-orchestration
/// detectors. Each finding is contextualised with the artifact's kind and
/// path. A parse failure surfaces as a single `artifact_parse_error_finding`.
fn analyze_referenced_artifact<F: FileSystemProvider, P: MarkdownParser>(
scanner: &Scanner<F, P>,
referenced_file: &Path,
) -> Vec<Finding> {
let fs = scanner.file_discovery().fs_provider();
let artifact_kind = crate::scanner_graph::artifact_kind_for_path::<F>(referenced_file);
let artifact_path = referenced_file.display().to_string();
let artifact_doc =
match SkillDocument::from_file_with_provider(referenced_file, scanner.parser(), fs) {
Ok(doc) => doc,
Err(err) => {
return vec![artifact_parse_error_finding(
referenced_file,
artifact_kind,
&err.to_string(),
)];
}
};
let mut findings = Vec::new();
if let Some(kind) = artifact_doc.binary_disguise_kind.as_deref() {
findings.push(binary_disguise_finding(
referenced_file,
kind,
artifact_kind,
MatchTarget::ReferencedFile {
path: artifact_path.clone(),
},
));
}
findings.extend(
scanner
.engine()
.evaluate(&artifact_doc)
.into_iter()
.map(|finding| {
finding
.with_match_target(MatchTarget::ReferencedFile {
path: artifact_path.clone(),
})
.with_artifact(artifact_kind, artifact_path.as_str())
}),
);
if artifact_doc.decode_warning {
findings.push(decode_warning_finding(referenced_file, artifact_kind));
}
if let Some(parse_warning) =
structured_parse_warning(referenced_file, &artifact_doc.raw_content, artifact_kind)
{
findings.push(parse_warning);
}
let sibling_files = crate::scanner_graph::sibling_files(fs, referenced_file);
let orchestrator_findings = scanner.artifact_orchestration().analyze(
referenced_file,
&artifact_doc.raw_content,
&sibling_files,
Some(&artifact_doc),
);
findings.extend(contextualize_findings(
orchestrator_findings,
artifact_kind,
artifact_path.as_str(),
));
findings
}
/// Build the list of supporting-artifact paths to evaluate for a skill document.
///
/// Includes every path extracted from the markdown (`doc.referenced_files`) plus,
/// when the document is an explicit skill entrypoint, any co-located scripts
/// and data-bearing files under the package root. The latter catches payloads
/// that malicious skills reference via absolute-looking paths (e.g.
/// `~/.openclaw/skills/.../x.sh`) or hide inside config / `.txt` blobs that
/// the markdown never mentions at all.
fn collect_supporting_artifact_paths<F: FileSystemProvider, P: MarkdownParser>(
scanner: &Scanner<F, P>,
doc: &SkillDocument,
) -> Vec<PathBuf> {
let mut artifacts = Vec::new();
let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
for referenced in &doc.referenced_files {
if seen.insert(referenced.clone()) {
artifacts.push(referenced.clone());
}
}
if !FileDiscoveryService::<F>::is_explicit_skill_file(&doc.path) {
return artifacts;
}
let Some(package_root) = doc.path.parent() else {
return artifacts;
};
let discovery = scanner.file_discovery();
for discovered in discovery.discover_package_scripts(package_root) {
if discovered == doc.path {
continue;
}
if seen.insert(discovered.clone()) {
artifacts.push(discovered);
}
}
for discovered in discovery.discover_package_data_files(package_root) {
if discovered == doc.path {
continue;
}
if seen.insert(discovered.clone()) {
artifacts.push(discovered);
}
}
artifacts
}
pub(crate) fn scan_document_path<F: FileSystemProvider, P: MarkdownParser>(
scanner: &Scanner<F, P>,
path: &Path,
) -> Result<ScanResult, ScanError> {
let doc = SkillDocument::from_file_with_provider(
path,
scanner.parser(),
scanner.file_discovery().fs_provider(),
)?;
let artifact_kind = crate::scanner_graph::artifact_kind_for_path::<F>(path);
let artifact_path = path.display().to_string();
let primary_content = doc.raw_content.clone();
let (raw_findings, artifact_graph) = collect_raw_findings(
scanner,
&doc,
path,
artifact_kind,
&artifact_path,
&primary_content,
);
let (raw_findings, suppressed_findings) =
collect_and_apply_suppressions(scanner, raw_findings, path, &doc, &primary_content);
let (findings, deduplication_summary) = deduplicate_findings(raw_findings);
let inline_suppressed = suppressed_findings.len();
let filter_outcome = scanner.filter_service().filter_with_summary(findings);
let filtered_findings = filter_outcome.findings;
let (
primary_findings,
supporting_findings,
summary,
primary_summary,
supporting_summary,
verdict_report,
) = build_verdict_and_summaries(&filtered_findings, &artifact_graph, path, artifact_kind);
let should_fail = scanner.filter_service().should_fail(&filtered_findings);
let extracted_iocs = collect_extracted_iocs(scanner, &doc, path, &primary_content);
let metadata = build_artifact_metadata(path, &doc, artifact_kind);
Ok(ScanResult {
metadata,
findings: filtered_findings,
suppressed_findings,
primary_findings,
supporting_findings,
summary,
primary_summary,
supporting_summary,
verdict: verdict_report.verdict,
verdict_report,
deduplication_summary,
artifact_graph,
profile: scanner.filter_service().profile(),
policy: scanner.filter_service().policy().cloned(),
suppression_summary: build_suppression_summary(
inline_suppressed,
filter_outcome.suppression_summary,
),
policy_audit: build_policy_audit(scanner, filter_outcome.applied_overrides),
should_fail,
extracted_iocs,
})
}
/// Snapshot the document-level metadata that the `ScanResult` carries
/// alongside findings. Pure data marshaling; clones owned strings so the
/// caller can keep using `doc` for downstream IOC extraction.
fn build_artifact_metadata(
path: &Path,
doc: &SkillDocument,
artifact_kind: ArtifactKind,
) -> crate::scanner_types::ArtifactMetadata {
crate::scanner_types::ArtifactMetadata {
path: path.to_path_buf(),
name: doc.name.clone(),
extension_kind: doc.extension_kind,
classification: doc.classification,
package_id: crate::scanner_graph::derive_package_id(path),
identity_source: doc.identity_source,
structural_validity: doc.structural_validity,
heuristic_score: doc.structural_signals.score,
primary_artifact_kind: artifact_kind,
}
}
/// Compute the package-wide finding summary, the per-scope summaries, and the
/// verdict report from the post-policy findings + artifact graph.
///
/// Scope-specific summaries use finding-only scoring (no graph capabilities)
/// so that `primary_summary.risk_score` reflects only primary-artifact risk,
/// not capabilities from supporting artifacts (and vice versa).
fn build_verdict_and_summaries(
filtered_findings: &[Finding],
artifact_graph: &ArtifactGraph,
path: &Path,
artifact_kind: ArtifactKind,
) -> (
Vec<Finding>,
Vec<Finding>,
FindingSummary,
FindingSummary,
FindingSummary,
PackageVerdictReport,
) {
let (primary_findings, supporting_findings) =
ScanResult::split_findings_by_scope(path, artifact_kind, filtered_findings);
let summary = FindingSummary::from_findings_and_graph(filtered_findings, artifact_graph);
let primary_summary = FindingSummary::from_findings(&primary_findings);
let supporting_summary = FindingSummary::from_findings(&supporting_findings);
let verdict_report = derive_package_verdict(
filtered_findings,
&primary_summary,
&supporting_summary,
&summary,
);
(
primary_findings,
supporting_findings,
summary,
primary_summary,
supporting_summary,
verdict_report,
)
}
/// Collect IOCs from the primary document and every supporting artifact. Runs
/// offline (no network) and feeds downstream enrichment tooling.
///
/// # Hash-fidelity contract
///
/// The primary artifact MUST be hashed over its raw on-disk bytes, NOT
/// over the lossy-decoded UTF-8 string in `doc.raw_content`. The lossy
/// decode replaces every invalid byte with U+FFFD (`0xEF 0xBF 0xBD` in
/// UTF-8), so a binary-disguised skill (ZIP/PE/ELF embedded in `.md`)
/// would otherwise hash to a digest that disagrees with `sha256sum` on
/// disk and breaks VT cross-check exactly when it matters most. The
/// fix re-reads the raw bytes through the `FileSystemProvider` port so
/// the SHA-256 in `ExtractedIocs.file_hashes` round-trips with stock
/// hashing tools.
fn collect_extracted_iocs<F: FileSystemProvider, P: MarkdownParser>(
scanner: &Scanner<F, P>,
doc: &SkillDocument,
primary_path: &Path,
primary_content: &str,
) -> crate::ioc_extraction::ExtractedIocs {
let fs = scanner.file_discovery().fs_provider();
// IOC extraction has two output groups:
// - URL/IP/domain tokens, which can come from the lossy-decoded
// string without changing the result (they're ASCII-only).
// - File hashes, which MUST be computed over the on-disk bytes.
// We re-read the primary file once: the cost is one extra
// `read_file_bytes` per scan and the digest is correct.
// When re-reading the file fails, we use `extract_from_text` (which omits
// the file hash) rather than hashing the lossy-decoded content. The
// lossy-decoded string replaces invalid UTF-8 bytes with U+FFFD, so a
// SHA-256 computed over it would disagree with `sha256sum` on the raw
// file — exactly when an accurate hash is most needed (binary-disguised
// malware payloads). Skipping the hash is safer than producing a wrong one.
let mut iocs = match fs.read_file_bytes(primary_path) {
Ok(file) => crate::ioc_extraction::extract_from_artifact(primary_path, file.as_bytes()),
Err(err) => {
tracing::warn!(
path = %primary_path.display(),
error = %err,
"IOC extraction: failed to re-read primary artifact for SHA-256; \
skipping file hash (URL/IP/domain extraction continues)"
);
crate::ioc_extraction::extract_from_text(primary_content)
}
};
let supporting = collect_supporting_artifact_paths(scanner, doc);
for path in supporting {
// Reject symlinks, FIFOs, and device files before reading — mirrors
// the guard in scan_supporting_artifacts. Without this, a malicious
// skill referencing /dev/urandom or a symlink → /etc/shadow would
// leak sensitive content into IOC extraction.
if !fs.exists(&path) || !fs.is_file(&path) {
continue;
}
match fs.read_file_bytes(&path) {
Ok(file) => {
let bytes = file.as_bytes();
iocs.merge(crate::ioc_extraction::extract_from_artifact(&path, bytes));
}
Err(e) => tracing::warn!(
"ioc-extraction: skipping supporting artifact {}: {e}",
path.display()
),
}
}
iocs
}
fn collect_raw_findings<F: FileSystemProvider, P: MarkdownParser>(
scanner: &Scanner<F, P>,
doc: &SkillDocument,
path: &Path,
artifact_kind: ArtifactKind,
artifact_path: &str,
primary_content: &str,
) -> (Vec<Finding>, ArtifactGraph) {
let mut findings = scanner.engine().evaluate(doc);
findings.extend(collect_primary_doc_warnings::<F>(doc, path));
findings.extend(scan_supporting_artifacts(scanner, doc));
findings.extend(deceptive_docs_findings(scanner, doc));
if let Some(w) = structured_parse_warning(path, primary_content, artifact_kind) {
findings.push(w);
}
let sibling_files =
crate::scanner_graph::sibling_files(scanner.file_discovery().fs_provider(), path);
findings.extend(scanner.artifact_orchestration().analyze(
path,
primary_content,
&sibling_files,
Some(doc),
));
let artifact_graph = scanner.build_artifact_graph(doc);
let taint_findings = crate::artifact_taint::derive_taint_findings(&artifact_graph, &findings);
// Preserve findings that already have artifact context (e.g., from supporting artifact
// analysis). Only tag uncontextualized findings with the primary artifact.
findings = contextualize_findings(findings, artifact_kind, artifact_path);
findings.extend(taint_findings);
(findings, artifact_graph)
}
/// Run the claim-vs-behavior detector. Reads each supporting artifact via the
/// scanner's filesystem provider; I/O errors are logged and the artifact is
/// excluded from deceptive-docs evaluation (but not silently swallowed).
fn deceptive_docs_findings<F: FileSystemProvider, P: MarkdownParser>(
scanner: &Scanner<F, P>,
doc: &SkillDocument,
) -> Vec<Finding> {
let supporting = collect_supporting_artifact_paths(scanner, doc);
if supporting.is_empty() {
return Vec::new();
}
let fs = scanner.file_discovery().fs_provider();
let materialised: Vec<(PathBuf, String)> = supporting
.into_iter()
.filter(|p| fs.exists(p) && fs.is_file(p))
.filter_map(|p| match read_text_file_lossy(&p, fs) {
Ok((c, _)) => Some((p, c)),
Err(e) => {
tracing::warn!("deceptive-docs: skipping {}: {e}", p.display());
None
}
})
.collect();
crate::deceptive_docs::detect_deceptive_documentation(doc, &materialised)
}
fn collect_primary_doc_warnings<F: FileSystemProvider>(
doc: &SkillDocument,
path: &Path,
) -> Vec<Finding> {
let artifact_kind = crate::scanner_graph::artifact_kind_for_path::<F>(path);
let mut warnings = Vec::new();
if let Some(kind) = doc.binary_disguise_kind.as_deref() {
warnings.push(binary_disguise_finding(
path,
kind,
artifact_kind,
MatchTarget::Document,
));
}
if doc.decode_warning {
warnings.push(decode_warning_finding(path, artifact_kind));
}
if doc.parse_warning {
warnings.push(parse_warning_finding(
path,
artifact_kind,
"Markdown sections could not be fully parsed; analysis continued with defensive fallback",
));
}
warnings
}
fn build_suppression_summary(
inline_suppressed: usize,
base: SuppressionSummary,
) -> SuppressionSummary {
SuppressionSummary {
inline_suppressed,
..base
}
}
fn build_policy_audit<F: FileSystemProvider, P: MarkdownParser>(
scanner: &Scanner<F, P>,
applied_overrides: Vec<AppliedPolicyOverride>,
) -> PolicyAudit {
PolicyAudit {
precedence_order: POLICY_AUDIT_PRECEDENCE
.iter()
.map(|s| (*s).to_string())
.collect(),
effective_fail_on: scanner.filter_service().fail_on(),
applied_overrides,
}
}
fn contextualize_findings(
findings: Vec<Finding>,
artifact_kind: crate::ArtifactKind,
artifact_path: &str,
) -> Vec<Finding> {
findings
.into_iter()
.map(|f| {
if f.artifact_path.is_some() {
f
} else {
f.with_artifact(artifact_kind, artifact_path.to_string())
}
})
.collect()
}
/// Collect inline suppression sources from the primary document and its
/// referenced files, then apply per-finding suppressions.
///
/// # Why this runs BEFORE deduplication
///
/// `deduplicate_findings` merges findings on
/// `(rule_id, category, matched_on, match_value, kind, scope, path)` and only
/// preserves the first non-`None` `line_number` it sees. If two emissions of
/// the same rule reach `scan_document_path` with different line numbers (one
/// carrying a `// skill-veil:disable` comment line, another path-less from
/// artifact-graph taint), running suppressions afterwards would let the merged
/// finding survive when its representative line happens to be the
/// non-suppressed copy. Suppressing first ensures each emission is matched
/// against its own original line number.
fn collect_and_apply_suppressions<F: FileSystemProvider, P: MarkdownParser>(
scanner: &Scanner<F, P>,
findings: Vec<Finding>,
path: &Path,
doc: &SkillDocument,
primary_content: &str,
) -> (Vec<Finding>, Vec<Finding>) {
let fs = scanner.file_discovery().fs_provider();
let supporting_artifacts = collect_supporting_artifact_paths(scanner, doc);
let mut seen_paths: BTreeSet<PathBuf> = supporting_artifacts.iter().cloned().collect();
let mut ref_contents: Vec<(PathBuf, String)> = Vec::new();
for referenced_file in &supporting_artifacts {
// Reject symlinks, FIFOs, and device files before reading inline
// suppressions — mirrors the guard in scan_supporting_artifacts.
if !fs.exists(referenced_file) || !fs.is_file(referenced_file) {
continue;
}
match read_text_file_lossy(referenced_file, fs) {
Ok((ref_content, _)) => {
ref_contents.push((referenced_file.clone(), ref_content));
}
Err(e) => tracing::warn!(
"suppression: cannot read referenced file {}: {e}",
referenced_file.display()
),
}
}
// Taint findings reference source/sink files that may not appear in
// `doc.referenced_files`. Without their content, `# skill-veil:ignore`
// comments in those files have no effect on taint findings.
for finding in &findings {
if let Some(ref artifact_path) = finding.artifact_path {
let artifact_pb = PathBuf::from(artifact_path.as_str());
if seen_paths.insert(artifact_pb.clone())
&& fs.exists(&artifact_pb)
&& fs.is_file(&artifact_pb)
{
match read_text_file_lossy(&artifact_pb, fs) {
Ok((content, _)) => {
ref_contents.push((artifact_pb, content));
}
Err(e) => tracing::warn!(
"suppression: cannot read taint artifact {}: {e}",
artifact_path
),
}
}
}
}
let mut suppression_sources: Vec<(&Path, &str)> = Vec::with_capacity(1 + ref_contents.len());
suppression_sources.push((path, primary_content));
for (ref_path, ref_content) in &ref_contents {
suppression_sources.push((ref_path.as_path(), ref_content.as_str()));
}
let inline_suppressions =
crate::inline_suppressions::collect_inline_suppressions(&suppression_sources);
let primary_path_str = path.display().to_string();
crate::inline_suppressions::apply_inline_suppressions(
findings,
&inline_suppressions,
Some(&primary_path_str),
)
}
pub(crate) fn discover_package_targets<F: FileSystemProvider, P: MarkdownParser>(
scanner: &Scanner<F, P>,
path: &Path,
) -> Result<Vec<PathBuf>, ScanError> {
let mut entrypoints = scanner.file_discovery().discover_skill_entrypoints(path);
if entrypoints.is_empty() {
entrypoints = scanner.file_discovery().discover_heuristic_candidates(path);
}
if entrypoints.is_empty() {
return Err(ScanError::NoSkillEntrypoints(path.to_path_buf()));
}
let mut targets = BTreeSet::new();
for entrypoint in entrypoints {
targets.insert(entrypoint);
}
for manifest in scanner.file_discovery().discover_package_manifests(path) {
targets.insert(manifest);
}
for lockfile in scanner.file_discovery().discover_lockfiles(path) {
targets.insert(lockfile);
}
Ok(targets.into_iter().collect())
}
#[cfg(test)]
mod scan_supporting_artifacts_tests {
/// Architectural contract: `scan_supporting_artifacts` MUST consult
/// the `FileSystemProvider` port for BOTH existence and directory
/// checks, not `Path::exists` / `Path::is_dir` directly. Mixing the
/// two backends opens a TOCTOU window and lets test doubles disagree
/// with production behaviour. Pre-fix the is_dir check bypassed the
/// port by calling Path::is_dir directly (follows symlinks), while
/// rejects symlinks — a malicious package shipping `evil_dir -> /etc`
/// would be silently skipped instead of surfaced as a read error.
///
/// The `!fs.is_file()` guard also rejects symlinks-to-directories,
/// FIFOs, and device files — all of which would cause `read_file_bytes`
/// to hang (FIFO), produce unbounded reads (devices), or spurious
/// parse errors (symlink-to-directory).
#[test]
fn scan_supporting_artifacts_uses_fs_provider_for_existence_and_is_file() {
let body = include_str!("scanner_execution.rs");
let production = body.split("#[cfg(test)]").next().unwrap_or(body);
let Some(after_sig) = production.split("fn scan_supporting_artifacts<").nth(1) else {
// The signature renamed; surface a single, actionable failure
// message rather than a generic .expect panic stack.
panic!(
"scanner_execution.rs no longer contains a `fn scan_supporting_artifacts<...>` \
production definition. If you renamed it, update this contract test to track \
the new name."
);
};
let in_function = after_sig.split("\nfn ").next().unwrap_or(after_sig);
assert!(
!in_function.contains("referenced_file.exists()"),
"scan_supporting_artifacts must not call Path::exists directly; \
route existence checks through the FileSystemProvider port to \
keep test doubles consistent with production behaviour"
);
assert!(
!in_function.contains(".is_dir()"),
"scan_supporting_artifacts must not call Path::is_dir directly; \
Path::is_dir follows symlinks and bypasses the FileSystemProvider port, \
which uses symlink_metadata and rejects symlinks."
);
assert!(
in_function.contains("fs.exists(referenced_file)"),
"scan_supporting_artifacts must use fs.exists(referenced_file) so \
that mock providers and production share the same code path"
);
assert!(
in_function.contains("fs.is_file(referenced_file)"),
"scan_supporting_artifacts must use fs.is_file(referenced_file) so \
that symlinks, FIFOs, and device files are rejected via the \
FileSystemProvider port's symlink_metadata check instead of \
causing hangs or unbounded reads"
);
}
}