From bad43c7e293dad7be3e04e689181a98f9661c1e4 Mon Sep 17 00:00:00 2001 From: Ronaldo Martins Date: Fri, 24 Jul 2026 00:14:02 -0300 Subject: [PATCH 1/2] feat(cli): add safe local config discovery --- CLAUDE.md | 4 +- Cargo.lock | 1 + Cargo.toml | 4 + README.md | 4 + src/bin/cli.rs | 30 ++ src/bin/cli/discover.rs | 175 +++++++ src/discovery/filesystem.rs | 887 ++++++++++++++++++++++++++++++++++++ src/discovery/mod.rs | 36 +- src/lib.rs | 1 - tests/discovery_cli.rs | 96 ++++ 10 files changed, 1228 insertions(+), 10 deletions(-) create mode 100644 src/bin/cli/discover.rs create mode 100644 src/discovery/filesystem.rs create mode 100644 tests/discovery_cli.rs diff --git a/CLAUDE.md b/CLAUDE.md index 8d993f0..b2a12d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,8 @@ agentshield/ ├── src/ │ ├── lib.rs # Public API: scan(), render_report() │ ├── error.rs # ShieldError (thiserror) -│ ├── bin/cli.rs # Clap CLI: scan, list-rules, init, suppress, list-suppressions, certify +│ ├── bin/cli.rs # Clap CLI: discover, scan, setup, reporting, runtime +│ ├── discovery/ # CLI-private registry, parsers, and safe filesystem reads │ ├── ir/ # Intermediate Representation (ScanTarget) │ │ ├── mod.rs # ScanTarget, Framework, SourceFile, ArgumentSource │ │ ├── tool_surface.rs # Tool definitions, permissions @@ -102,6 +103,7 @@ cargo run -- scan tests/fixtures/mcp_servers/vuln_cmd_inject cargo run -- scan . --ignore-tests --format html --output report.html cargo run -- scan . --write-baseline baseline.json cargo run -- scan . --baseline baseline.json +cargo run -- discover --no-default-paths --root . cargo run -- list-rules cargo run -- suppress SHIELD-001 src/tools.py:42 --reason "accepted risk" cargo run -- list-suppressions diff --git a/Cargo.lock b/Cargo.lock index 18eb1cd..bb93b49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,7 @@ dependencies = [ "pretty_assertions", "proptest", "regex", + "rustix", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml index 0f4b1b8..2a04f59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,6 +82,10 @@ url = "2.5" tokio = { version = "1", features = ["full"], optional = true } parking_lot = { version = "0.12", optional = true } +[target.'cfg(unix)'.dependencies] +# Atomic handle-relative discovery reads. Already present transitively via tempfile. +rustix = { version = "1.1.3", features = ["fs"] } + [dev-dependencies] tempfile = "3.9" pretty_assertions = "1.4" diff --git a/README.md b/README.md index 6b8cc63..a810d57 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,9 @@ cargo install --git https://github.com/aiconnai/agentshield --tag v0.8.7 --featu # First-run setup: config + explained first scan agentshield quickstart +# Discover allowlisted local client configs without executing or scanning them +agentshield discover --no-default-paths --root . + # Understand the gate, coverage, confidence, and next actions agentshield scan . --ignore-tests --fail-on high --explain @@ -247,6 +250,7 @@ AgentShield runs all matching adapters in a repository instead of stopping at th | Command | Purpose | |---------|---------| +| `agentshield discover` | Read allowlisted local client configuration paths without execution, network access, or automatic scanning; use `--no-default-paths`, repeated `--root`, `--format console\|json`, and `--explain` to control consent and output. | | `agentshield scan [path]` | Scan an agent extension directory and emit console, JSON, SARIF, or HTML output. | | `agentshield scan [path] --explain` | Print a console-only gate, coverage, confidence, grouped findings, next-actions, and limits summary. | | `agentshield quickstart [path]` | Create first-run config, suggest CI setup, run the first scan, and explain the result. | diff --git a/src/bin/cli.rs b/src/bin/cli.rs index d898094..8748bfd 100644 --- a/src/bin/cli.rs +++ b/src/bin/cli.rs @@ -1,6 +1,10 @@ use std::path::PathBuf; use std::process; +#[path = "cli/discover.rs"] +mod discover; +#[path = "../discovery/mod.rs"] +mod discovery; #[path = "cli/reporting.rs"] mod reporting; #[path = "cli/rules.rs"] @@ -14,6 +18,7 @@ mod setup; use clap::{Parser, Subcommand}; +use discover::cmd_discover; use reporting::{cmd_certify, cmd_list_suppressions, cmd_suppress}; use rules::cmd_list_rules; #[cfg(feature = "runtime")] @@ -41,6 +46,25 @@ struct Cli { #[derive(Subcommand)] enum Commands { + /// Discover allowlisted local client configurations without scanning them + Discover { + /// Do not inspect known configuration paths under the effective profile + #[arg(long)] + no_default_paths: bool, + + /// Inspect allowlisted configuration layouts under this explicit root + #[arg(long, value_name = "PATH")] + root: Vec, + + /// Output format (console, json) + #[arg(long, short = 'f', default_value = "console")] + format: String, + + /// Explain paths, limits, and symlink policy before reading + #[arg(long)] + explain: bool, + }, + /// Scan an agent extension for security issues Scan { /// Path to the extension directory @@ -282,6 +306,12 @@ fn main() { let cli = Cli::parse(); let result = match cli.command { + Commands::Discover { + no_default_paths, + root, + format, + explain, + } => cmd_discover(!no_default_paths, root, format, explain), Commands::Scan { path, config, diff --git a/src/bin/cli/discover.rs b/src/bin/cli/discover.rs new file mode 100644 index 0000000..bad5d6a --- /dev/null +++ b/src/bin/cli/discover.rs @@ -0,0 +1,175 @@ +use std::path::PathBuf; + +use agentshield::error::{Result, ShieldError}; + +use crate::discovery::{ + discover, registry, DiscoveryBase, DiscoveryEnvelope, DiscoveryRequest, ENTRY_STATES, + MAX_AGGREGATE_BYTES, MAX_CANDIDATE_FILES_PER_INVOCATION, MAX_CANDIDATE_FILES_PER_ROOT, + MAX_CONFIG_BYTES, MAX_DEPTH_PER_ROOT, MAX_DIRECTORIES_PER_INVOCATION, MAX_DIRECTORIES_PER_ROOT, + MAX_ENTRIES_PER_INVOCATION, MAX_OPENED_CONFIGS_PER_INVOCATION, MAX_OPENED_CONFIGS_PER_ROOT, +}; + +pub(crate) fn cmd_discover( + include_default_paths: bool, + roots: Vec, + format: String, + explain: bool, +) -> Result { + let format = DiscoveryOutputFormat::parse(&format)?; + let request = DiscoveryRequest { + include_default_paths, + effective_profile: effective_profile(), + roots, + }; + + if explain { + eprint!("{}", render_explanation(&request)); + } + + let envelope = discover(&request).map_err(ShieldError::Config)?; + match format { + DiscoveryOutputFormat::Console => print!("{}", render_console(&envelope)), + DiscoveryOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&envelope)?), + } + Ok(0) +} + +fn effective_profile() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DiscoveryOutputFormat { + Console, + Json, +} + +impl DiscoveryOutputFormat { + fn parse(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "console" => Ok(Self::Console), + "json" => Ok(Self::Json), + _ => Err(ShieldError::Config(format!( + "unsupported discovery format '{value}'; expected console or json" + ))), + } + } +} + +fn render_explanation(request: &DiscoveryRequest) -> String { + let default_descriptors = registry() + .iter() + .filter(|descriptor| descriptor.base == DiscoveryBase::EffectiveProfile) + .map(|descriptor| format!(" - ~/{}\n", descriptor.relative_path)) + .collect::(); + let roots = request + .roots + .iter() + .enumerate() + .map(|(index, _)| format!(" - $ROOT[{index}]\n")) + .collect::(); + let entry_states = ENTRY_STATES + .iter() + .map(|state| state.as_str()) + .collect::>() + .join(", "); + + format!( + "Discovery plan (read-only; no scan or execution)\n\ + Default paths: {}\n{}\ + Explicit roots:\n{}\ + Limits: depth={MAX_DEPTH_PER_ROOT}, directories={MAX_DIRECTORIES_PER_ROOT}/root \ + and {MAX_DIRECTORIES_PER_INVOCATION}/invocation, candidates={MAX_CANDIDATE_FILES_PER_ROOT}/root \ + and {MAX_CANDIDATE_FILES_PER_INVOCATION}/invocation, opened={MAX_OPENED_CONFIGS_PER_ROOT}/root \ + and {MAX_OPENED_CONFIGS_PER_INVOCATION}/invocation, file_bytes={MAX_CONFIG_BYTES}, \ + aggregate_bytes={MAX_AGGREGATE_BYTES}, entries={MAX_ENTRIES_PER_INVOCATION}\n\ + Symlinks and special files are never followed or read. Platforms without an atomic \ + no-follow primitive fail closed.\n\ + Entry states: {entry_states}.\n", + if request.include_default_paths { + "enabled" + } else { + "disabled" + }, + if request.include_default_paths { + default_descriptors + } else { + String::new() + }, + if roots.is_empty() { + " (none)\n".to_owned() + } else { + roots + }, + ) +} + +fn render_console(envelope: &DiscoveryEnvelope) -> String { + let mut output = format!( + "AgentShield discovery: {} source(s), {} entry/entries, {} diagnostic(s)\n", + envelope.summary.sources, envelope.summary.entries, envelope.summary.diagnostics + ); + + for source in &envelope.sources { + output.push_str(&format!( + "\n{} [{}] ({})\n", + source.path_ref, + source.client_id.as_str(), + json_label(&source.status) + )); + for entry in envelope + .entries + .iter() + .filter(|entry| entry.source_id == source.source_id) + { + output.push_str(&format!( + " - {}: {} ({})\n", + entry.declared_name, + json_label(&entry.state), + json_label(&entry.support_status) + )); + } + for diagnostic in envelope + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.source_id == source.source_id) + { + output.push_str(&format!(" ! {}\n", json_label(&diagnostic.code))); + } + } + output +} + +fn json_label(value: &T) -> String { + serde_json::to_value(value) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_else(|| "unknown".to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_format_is_an_invocation_error() { + let error = DiscoveryOutputFormat::parse("sarif").expect_err("sarif is unsupported"); + assert!(error.to_string().contains("console or json")); + assert_eq!(error.exit_code(), 2); + } + + #[test] + fn explanation_redacts_explicit_roots() { + let request = DiscoveryRequest { + include_default_paths: false, + effective_profile: None, + roots: vec![PathBuf::from("/Users/alice/private")], + }; + let explanation = render_explanation(&request); + assert!(explanation.contains("$ROOT[0]")); + assert!(!explanation.contains("alice")); + assert!(!explanation.contains("/Users")); + } +} diff --git a/src/discovery/filesystem.rs b/src/discovery/filesystem.rs new file mode 100644 index 0000000..d42b462 --- /dev/null +++ b/src/discovery/filesystem.rs @@ -0,0 +1,887 @@ +#[cfg(unix)] +use std::collections::BTreeMap; +#[cfg(unix)] +use std::path::Path; +use std::path::{Component, PathBuf}; + +use super::{ + build_envelope, parse_source, registry, DiagnosticCode, DiscoveryBase, DiscoveryDescriptor, + DiscoveryDiagnostic, DiscoveryEnvelope, ParsedDiscoverySource, RedactedPathRef, SourceStatus, +}; +#[cfg(unix)] +use super::{ + DiscoveryMethod, ProvenanceObservation, MAX_AGGREGATE_BYTES, + MAX_CANDIDATE_FILES_PER_INVOCATION, MAX_CANDIDATE_FILES_PER_ROOT, + MAX_OPENED_CONFIGS_PER_INVOCATION, MAX_OPENED_CONFIGS_PER_ROOT, +}; + +#[derive(Debug)] +pub(crate) struct DiscoveryRequest { + pub include_default_paths: bool, + pub effective_profile: Option, + pub roots: Vec, +} + +pub(crate) fn discover(request: &DiscoveryRequest) -> Result { + platform::discover(request) +} + +#[cfg(unix)] +#[derive(Debug)] +struct Candidate<'a> { + descriptor: &'a DiscoveryDescriptor, + path_ref: RedactedPathRef, + method: DiscoveryMethod, + root_index: Option, +} + +#[cfg(unix)] +#[derive(Debug)] +struct OpenedCandidate<'a> { + candidate: Candidate<'a>, + identity: FileIdentity, + bytes: Vec, +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct FileIdentity { + device: u64, + inode: u64, +} + +#[cfg(unix)] +#[derive(Debug, Default)] +struct DiscoveryBudget { + directories: usize, + candidate_files: usize, + opened_configs: usize, + bytes: usize, +} + +fn failed_source( + descriptor: &DiscoveryDescriptor, + path_ref: &RedactedPathRef, + status: SourceStatus, + code: Option, +) -> ParsedDiscoverySource { + let mut parsed = parse_source(descriptor, path_ref, br#"{"mcpServers":{}}"#); + parsed.source.status = status; + parsed.entries.clear(); + parsed.diagnostics.clear(); + if let Some(code) = code { + parsed.diagnostics.push(DiscoveryDiagnostic { + code, + source_id: parsed.source.source_id.clone(), + }); + } + parsed +} + +#[cfg(unix)] +fn merge_opened(opened: Vec>) -> Vec { + let mut by_identity: BTreeMap>> = BTreeMap::new(); + for candidate in opened { + by_identity + .entry(candidate.identity) + .or_default() + .push(candidate); + } + + by_identity + .into_values() + .map(|mut observations| { + observations.sort_by_key(|opened| { + ( + opened.candidate.method, + opened.candidate.root_index, + opened.candidate.descriptor.id, + ) + }); + let primary = &observations[0]; + if observations + .iter() + .skip(1) + .any(|observation| observation.bytes != primary.bytes) + { + let mut changed = failed_source( + primary.candidate.descriptor, + &primary.candidate.path_ref, + SourceStatus::ChangeDetectedDuringRead, + Some(DiagnosticCode::ChangeDetectedDuringRead), + ); + changed.source.provenance = observations + .iter() + .map(|opened| ProvenanceObservation { + descriptor_id: opened.candidate.descriptor.id, + discovery_method: opened.candidate.method, + path_ref: opened.candidate.path_ref.as_str().to_owned(), + }) + .collect(); + return changed; + } + let mut parsed = parse_source( + primary.candidate.descriptor, + &primary.candidate.path_ref, + &primary.bytes, + ); + parsed.source.provenance = observations + .iter() + .map(|opened| ProvenanceObservation { + descriptor_id: opened.candidate.descriptor.id, + discovery_method: opened.candidate.method, + path_ref: opened.candidate.path_ref.as_str().to_owned(), + }) + .collect(); + parsed + }) + .collect() +} + +fn candidate_path_ref( + descriptor: &DiscoveryDescriptor, + root_index: Option, +) -> RedactedPathRef { + let value = match root_index { + Some(index) => format!("$ROOT[{index}]/{}", descriptor.relative_path), + None => format!("~/{}", descriptor.relative_path), + }; + RedactedPathRef::new(value).expect("registry paths always produce redacted references") +} + +#[cfg(unix)] +fn validate_relative_registry_path(path: &str) -> Result, String> { + let components = Path::new(path) + .components() + .map(|component| match component { + Component::Normal(value) => value + .to_str() + .ok_or_else(|| "registry path is not valid UTF-8".to_owned()), + _ => Err("registry path is not a strict relative path".to_owned()), + }) + .collect::, _>>()?; + if components.is_empty() { + return Err("registry path is empty".to_owned()); + } + Ok(components) +} + +#[cfg(unix)] +mod platform { + use std::fs::File; + use std::io::Read; + use std::os::unix::fs::MetadataExt; + use std::os::unix::io::OwnedFd; + + use rustix::fs::{open, openat, Mode, OFlags}; + use rustix::io::Errno; + + use super::*; + + const DIR_FLAGS: OFlags = OFlags::RDONLY + .union(OFlags::DIRECTORY) + .union(OFlags::NOFOLLOW) + .union(OFlags::CLOEXEC); + const FILE_FLAGS: OFlags = OFlags::RDONLY + .union(OFlags::NOFOLLOW) + .union(OFlags::CLOEXEC) + .union(OFlags::NONBLOCK); + + pub(super) fn discover(request: &DiscoveryRequest) -> Result { + let mut parsed = Vec::new(); + let mut opened = Vec::new(); + let mut invocation_budget = DiscoveryBudget::default(); + let mut continue_discovery = true; + + if request.include_default_paths { + if let Some(profile) = &request.effective_profile { + match open_root(profile, "effective profile") { + Ok(root) => { + let mut profile_budget = DiscoveryBudget::default(); + continue_discovery = inspect_descriptors( + &root, + DiscoveryBase::EffectiveProfile, + None, + &mut invocation_budget, + &mut profile_budget, + &mut opened, + &mut parsed, + )?; + } + Err(_) => { + for descriptor in registry() + .iter() + .filter(|descriptor| descriptor.base == DiscoveryBase::EffectiveProfile) + { + let path_ref = candidate_path_ref(descriptor, None); + parsed.push(failed_source( + descriptor, + &path_ref, + SourceStatus::UnsupportedFilesystemSafety, + Some(DiagnosticCode::UnsupportedFilesystemSafety), + )); + } + } + } + } + } + + if continue_discovery { + for (index, root_path) in request.roots.iter().enumerate() { + validate_user_root_syntax(root_path, index)?; + let root = open_root(root_path, &format!("root[{index}]")) + .map_err(|reason| format!("invalid root[{index}]: {reason}"))?; + if is_filesystem_root(&root)? { + return Err(format!( + "invalid root[{index}]: filesystem root is not allowed" + )); + } + let mut root_budget = DiscoveryBudget::default(); + if !inspect_descriptors( + &root, + DiscoveryBase::ExplicitRoot, + Some(index), + &mut invocation_budget, + &mut root_budget, + &mut opened, + &mut parsed, + )? { + break; + } + } + } + + parsed.extend(merge_opened(opened)); + Ok(build_envelope(parsed)) + } + + fn inspect_descriptors<'a>( + root: &OwnedFd, + base: DiscoveryBase, + root_index: Option, + invocation_budget: &mut DiscoveryBudget, + root_budget: &mut DiscoveryBudget, + opened: &mut Vec>, + parsed: &mut Vec, + ) -> Result { + for descriptor in registry() + .iter() + .filter(|descriptor| descriptor.base == base) + { + let directory_count = validate_relative_registry_path(descriptor.relative_path)? + .len() + .saturating_sub(1); + let limit_reached = invocation_budget.candidate_files + >= MAX_CANDIDATE_FILES_PER_INVOCATION + || root_budget.candidate_files >= MAX_CANDIDATE_FILES_PER_ROOT + || invocation_budget.opened_configs >= MAX_OPENED_CONFIGS_PER_INVOCATION + || root_budget.opened_configs >= MAX_OPENED_CONFIGS_PER_ROOT + || invocation_budget + .directories + .saturating_add(directory_count) + > super::super::MAX_DIRECTORIES_PER_INVOCATION + || root_budget.directories.saturating_add(directory_count) + > super::super::MAX_DIRECTORIES_PER_ROOT; + if limit_reached { + let path_ref = candidate_path_ref(descriptor, root_index); + parsed.push(failed_source( + descriptor, + &path_ref, + SourceStatus::LimitReached, + Some(DiagnosticCode::LimitReached), + )); + return Ok(false); + } + invocation_budget.candidate_files += 1; + root_budget.candidate_files += 1; + invocation_budget.directories += directory_count; + root_budget.directories += directory_count; + + let path_ref = candidate_path_ref(descriptor, root_index); + let candidate = Candidate { + descriptor, + path_ref, + method: if root_index.is_some() { + DiscoveryMethod::ExplicitRoot + } else { + DiscoveryMethod::KnownPath + }, + root_index, + }; + match open_candidate(root, descriptor.relative_path, &mut invocation_budget.bytes) { + Ok(Some((identity, bytes))) => { + invocation_budget.opened_configs += 1; + root_budget.opened_configs += 1; + opened.push(OpenedCandidate { + candidate, + identity, + bytes, + }); + } + Ok(None) => {} + Err(OpenFailure::PermissionDenied) => parsed.push(failed_source( + descriptor, + &candidate.path_ref, + SourceStatus::PermissionDenied, + Some(DiagnosticCode::PermissionDenied), + )), + Err(OpenFailure::Unsupported) => parsed.push(failed_source( + descriptor, + &candidate.path_ref, + SourceStatus::Unsupported, + None, + )), + Err(OpenFailure::UnsafeFilesystem) => parsed.push(failed_source( + descriptor, + &candidate.path_ref, + SourceStatus::UnsupportedFilesystemSafety, + Some(DiagnosticCode::UnsupportedFilesystemSafety), + )), + Err(OpenFailure::Changed) => parsed.push(failed_source( + descriptor, + &candidate.path_ref, + SourceStatus::ChangeDetectedDuringRead, + Some(DiagnosticCode::ChangeDetectedDuringRead), + )), + Err(OpenFailure::ConfigLimitReached) => parsed.push(failed_source( + descriptor, + &candidate.path_ref, + SourceStatus::LimitReached, + Some(DiagnosticCode::ConfigSizeLimitReached), + )), + Err(OpenFailure::InvocationLimitReached) => { + parsed.push(failed_source( + descriptor, + &candidate.path_ref, + SourceStatus::LimitReached, + Some(DiagnosticCode::LimitReached), + )); + return Ok(false); + } + } + } + Ok(true) + } + + fn validate_user_root_syntax(path: &Path, index: usize) -> Result<(), String> { + if path.as_os_str().is_empty() { + return Err(format!("invalid root[{index}]: path is empty")); + } + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(format!( + "invalid root[{index}]: parent components are not allowed" + )); + } + Ok(()) + } + + fn open_root(path: &Path, label: &str) -> Result { + let (mut current, components) = if path.is_absolute() { + ( + open("/", DIR_FLAGS, Mode::empty()) + .map_err(|_| format!("{label} cannot be opened safely"))?, + path.components().skip(1).collect::>(), + ) + } else { + ( + open(".", DIR_FLAGS, Mode::empty()) + .map_err(|_| format!("{label} cannot be opened safely"))?, + path.components().collect::>(), + ) + }; + + for component in components { + match component { + Component::CurDir => {} + Component::Normal(name) => { + current = openat(¤t, name, DIR_FLAGS, Mode::empty()) + .map_err(|_| format!("{label} is missing, inaccessible, or unsafe"))?; + } + _ => return Err(format!("{label} contains an unsupported path component")), + } + } + Ok(current) + } + + fn is_filesystem_root(root: &OwnedFd) -> Result { + let parent = openat(root, "..", DIR_FLAGS, Mode::empty()) + .map_err(|_| "root parent cannot be opened safely".to_owned())?; + Ok(identity_for_metadata( + &File::from(parent) + .metadata() + .map_err(|_| "root parent metadata is unavailable".to_owned())?, + ) == identity_for_metadata( + &File::from( + root.try_clone() + .map_err(|_| "root handle cannot be cloned".to_owned())?, + ) + .metadata() + .map_err(|_| "root metadata is unavailable".to_owned())?, + )) + } + + fn open_candidate( + root: &OwnedFd, + relative_path: &str, + aggregate_bytes: &mut usize, + ) -> Result)>, OpenFailure> { + open_candidate_with_hook(root, relative_path, aggregate_bytes, || {}) + } + + fn open_candidate_with_hook( + root: &OwnedFd, + relative_path: &str, + aggregate_bytes: &mut usize, + after_open: impl FnOnce(), + ) -> Result)>, OpenFailure> { + let components = validate_relative_registry_path(relative_path) + .map_err(|_| OpenFailure::UnsafeFilesystem)?; + let mut current = root + .try_clone() + .map_err(|_| OpenFailure::UnsafeFilesystem)?; + for component in &components[..components.len() - 1] { + current = match openat(¤t, *component, DIR_FLAGS, Mode::empty()) { + Ok(fd) => fd, + Err(Errno::NOENT) => return Ok(None), + Err(Errno::ACCESS | Errno::PERM) => return Err(OpenFailure::PermissionDenied), + Err(_) => return Err(OpenFailure::UnsafeFilesystem), + }; + } + + let fd = match openat( + ¤t, + components[components.len() - 1], + FILE_FLAGS, + Mode::empty(), + ) { + Ok(fd) => fd, + Err(Errno::NOENT) => return Ok(None), + Err(Errno::ACCESS | Errno::PERM) => return Err(OpenFailure::PermissionDenied), + Err(_) => return Err(OpenFailure::UnsafeFilesystem), + }; + let mut file = File::from(fd); + let before = file.metadata().map_err(|_| OpenFailure::UnsafeFilesystem)?; + if !before.file_type().is_file() { + return Err(OpenFailure::Unsupported); + } + if before.len() > super::super::MAX_CONFIG_BYTES as u64 { + return Err(OpenFailure::ConfigLimitReached); + } + if *aggregate_bytes >= MAX_AGGREGATE_BYTES { + return Err(OpenFailure::InvocationLimitReached); + } + after_open(); + let available = MAX_AGGREGATE_BYTES - *aggregate_bytes; + let read_limit = (super::super::MAX_CONFIG_BYTES + 1).min(available.saturating_add(1)); + let mut bytes = Vec::with_capacity((before.len() as usize).min(read_limit)); + file.by_ref() + .take(read_limit as u64) + .read_to_end(&mut bytes) + .map_err(|_| OpenFailure::Changed)?; + if bytes.len() > super::super::MAX_CONFIG_BYTES || bytes.len() > available { + return Err(if bytes.len() > super::super::MAX_CONFIG_BYTES { + OpenFailure::ConfigLimitReached + } else { + OpenFailure::InvocationLimitReached + }); + } + let after = file.metadata().map_err(|_| OpenFailure::Changed)?; + if metadata_signature(&before) != metadata_signature(&after) { + return Err(OpenFailure::Changed); + } + *aggregate_bytes += bytes.len(); + Ok(Some((identity_for_metadata(&after), bytes))) + } + + #[cfg(test)] + pub(super) fn open_candidate_with_hook_for_test( + root_path: &Path, + relative_path: &str, + after_open: impl FnOnce(), + ) -> Result>, &'static str> { + let root = open_root(root_path, "test root").map_err(|_| "root")?; + let mut aggregate_bytes = 0; + match open_candidate_with_hook(&root, relative_path, &mut aggregate_bytes, after_open) { + Ok(result) => Ok(result.map(|(_, bytes)| bytes)), + Err(OpenFailure::Changed) => Err("changed"), + Err(_) => Err("other"), + } + } + + fn identity_for_metadata(metadata: &std::fs::Metadata) -> FileIdentity { + FileIdentity { + device: metadata.dev(), + inode: metadata.ino(), + } + } + + fn metadata_signature(metadata: &std::fs::Metadata) -> (u64, u64, u64, i64, i64, i64, i64) { + ( + metadata.dev(), + metadata.ino(), + metadata.size(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + ) + } + + enum OpenFailure { + PermissionDenied, + Unsupported, + UnsafeFilesystem, + Changed, + ConfigLimitReached, + InvocationLimitReached, + } +} + +#[cfg(not(unix))] +mod platform { + use super::*; + + pub(super) fn discover(request: &DiscoveryRequest) -> Result { + for (index, root) in request.roots.iter().enumerate() { + if root.as_os_str().is_empty() + || root + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(format!("invalid root[{index}]: path is not allowed")); + } + let metadata = std::fs::symlink_metadata(root) + .map_err(|_| format!("invalid root[{index}]: path is unavailable"))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(format!( + "invalid root[{index}]: path is not a regular directory" + )); + } + if root.parent().is_none() { + return Err(format!( + "invalid root[{index}]: filesystem root is not allowed" + )); + } + } + + let mut parsed = Vec::new(); + for descriptor in registry() { + let root_indices: Vec> = match descriptor.base { + DiscoveryBase::EffectiveProfile if request.include_default_paths => vec![None], + DiscoveryBase::ExplicitRoot => (0..request.roots.len()).map(Some).collect(), + _ => Vec::new(), + }; + for root_index in root_indices { + let path_ref = candidate_path_ref(descriptor, root_index); + parsed.push(failed_source( + descriptor, + &path_ref, + SourceStatus::UnsupportedFilesystemSafety, + Some(DiagnosticCode::UnsupportedFilesystemSafety), + )); + } + } + Ok(build_envelope(parsed)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn registry_paths_are_strict_relative_paths() { + for descriptor in registry() { + assert!(validate_relative_registry_path(descriptor.relative_path).is_ok()); + } + } + + #[cfg(unix)] + #[test] + fn explicit_root_reads_only_allowlisted_regular_files() { + let directory = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(directory.path().join(".cursor")).expect("cursor dir"); + std::fs::write( + directory.path().join(".cursor/mcp.json"), + br#"{"mcpServers":{"tools":{"command":"node","env":{"TOKEN":"secret"}}}}"#, + ) + .expect("fixture"); + std::fs::write(directory.path().join("not-allowlisted.json"), b"secret").expect("decoy"); + + let envelope = discover(&DiscoveryRequest { + include_default_paths: false, + effective_profile: None, + roots: vec![directory.path().canonicalize().expect("canonical tempdir")], + }) + .expect("discovery"); + + assert_eq!(envelope.summary.sources, 1); + assert_eq!(envelope.summary.entries, 1); + let serialized = serde_json::to_string(&envelope).expect("serialize"); + assert!(!serialized.contains("TOKEN")); + assert!(!serialized.contains("secret")); + assert!(!serialized.contains(directory.path().to_string_lossy().as_ref())); + } + + #[cfg(unix)] + #[test] + fn explicit_root_rejects_symlink_component() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().expect("tempdir"); + let outside = tempfile::tempdir().expect("outside"); + std::fs::write( + outside.path().join("mcp.json"), + br#"{"mcpServers":{"tools":{"command":"node"}}}"#, + ) + .expect("fixture"); + symlink(outside.path(), directory.path().join(".cursor")).expect("symlink"); + + let envelope = discover(&DiscoveryRequest { + include_default_paths: false, + effective_profile: None, + roots: vec![directory.path().canonicalize().expect("canonical tempdir")], + }) + .expect("discovery"); + + assert!(envelope.entries.is_empty()); + assert!(envelope.sources.iter().any(|source| { + source.status == SourceStatus::UnsupportedFilesystemSafety + && source.path_ref == "$ROOT[0]/.cursor/mcp.json" + })); + } + + #[cfg(unix)] + #[test] + fn symlink_config_is_never_followed() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().expect("tempdir"); + let outside = tempfile::tempdir().expect("outside"); + std::fs::write( + outside.path().join("config.json"), + br#"{"mcpServers":{"secret":{"command":"do-not-read"}}}"#, + ) + .expect("outside config"); + symlink( + outside.path().join("config.json"), + directory.path().join(".mcp.json"), + ) + .expect("symlink"); + + let envelope = discover(&DiscoveryRequest { + include_default_paths: false, + effective_profile: None, + roots: vec![directory.path().canonicalize().expect("canonical tempdir")], + }) + .expect("discovery"); + let serialized = serde_json::to_string(&envelope).expect("serialize"); + + assert!(envelope.entries.is_empty()); + assert!(envelope.sources.iter().any(|source| { + source.status == SourceStatus::UnsupportedFilesystemSafety + && source.path_ref == "$ROOT[0]/.mcp.json" + })); + assert!(!serialized.contains("do-not-read")); + assert!(!serialized.contains("secret")); + } + + #[cfg(unix)] + #[test] + fn special_file_at_config_path_is_not_read() { + let directory = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(directory.path().join(".mcp.json")).expect("directory decoy"); + + let envelope = discover(&DiscoveryRequest { + include_default_paths: false, + effective_profile: None, + roots: vec![directory.path().canonicalize().expect("canonical tempdir")], + }) + .expect("discovery"); + + assert!(envelope.entries.is_empty()); + assert!(envelope.sources.iter().any(|source| { + source.status == SourceStatus::Unsupported && source.path_ref == "$ROOT[0]/.mcp.json" + })); + } + + #[cfg(unix)] + #[test] + fn write_during_read_is_reported_as_changed() { + use std::io::Write; + + let directory = tempfile::tempdir().expect("tempdir"); + let config = directory.path().join(".mcp.json"); + std::fs::write(&config, br#"{"mcpServers":{"tools":{"command":"node"}}}"#).expect("config"); + let root = directory.path().canonicalize().expect("canonical root"); + + let result = platform::open_candidate_with_hook_for_test(&root, ".mcp.json", || { + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&config) + .expect("open config for concurrent write"); + file.write_all(b" ").expect("concurrent write"); + }); + + assert_eq!(result.expect_err("change must be detected"), "changed"); + } + + #[cfg(unix)] + #[test] + fn symlink_swap_after_open_never_changes_the_opened_content() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().expect("tempdir"); + let outside = tempfile::tempdir().expect("outside"); + let config = directory.path().join(".mcp.json"); + let moved = directory.path().join("opened.json"); + let benign = br#"{"mcpServers":{"benign":{"command":"node"}}}"#; + std::fs::write(&config, benign).expect("config"); + std::fs::write( + outside.path().join("target.json"), + br#"{"mcpServers":{"secret":{"command":"do-not-read"}}}"#, + ) + .expect("outside config"); + let root = directory.path().canonicalize().expect("canonical root"); + + let result = platform::open_candidate_with_hook_for_test(&root, ".mcp.json", || { + std::fs::rename(&config, &moved).expect("rename opened file"); + symlink(outside.path().join("target.json"), &config).expect("replacement symlink"); + }); + + match result { + Ok(Some(bytes)) => assert_eq!(bytes, benign), + Err("changed") => {} + other => panic!("unexpected swap result: {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn explicit_root_itself_cannot_be_a_symlink() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().expect("tempdir"); + let link_parent = tempfile::tempdir().expect("link parent"); + let link = link_parent + .path() + .canonicalize() + .expect("canonical link parent") + .join("root"); + symlink(directory.path(), &link).expect("symlink"); + + let error = discover(&DiscoveryRequest { + include_default_paths: false, + effective_profile: None, + roots: vec![link], + }) + .expect_err("symlink root must fail"); + assert!(error.contains("root[0]")); + assert!(!error.contains(directory.path().to_string_lossy().as_ref())); + } + + #[cfg(unix)] + #[test] + fn no_default_paths_does_not_read_home() { + let envelope = discover(&DiscoveryRequest { + include_default_paths: false, + effective_profile: None, + roots: Vec::new(), + }) + .expect("discovery"); + assert!(envelope.sources.is_empty()); + } + + #[cfg(unix)] + #[test] + fn known_and_explicit_observations_of_same_file_are_deduplicated() { + let directory = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(directory.path().join(".cursor")).expect("cursor dir"); + std::fs::write( + directory.path().join(".cursor/mcp.json"), + br#"{"mcpServers":{"tools":{"command":"node"}}}"#, + ) + .expect("fixture"); + let root = directory.path().canonicalize().expect("canonical root"); + + let envelope = discover(&DiscoveryRequest { + include_default_paths: true, + effective_profile: Some(root.clone()), + roots: vec![root], + }) + .expect("discovery"); + + assert_eq!(envelope.summary.sources, 1); + assert_eq!(envelope.sources[0].path_ref, "~/.cursor/mcp.json"); + assert_eq!(envelope.sources[0].provenance.len(), 2); + assert_eq!( + envelope.sources[0].provenance[0].discovery_method, + DiscoveryMethod::KnownPath + ); + assert_eq!( + envelope.sources[0].provenance[1].discovery_method, + DiscoveryMethod::ExplicitRoot + ); + } + + #[cfg(unix)] + #[test] + fn malformed_source_does_not_remove_valid_source() { + let directory = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(directory.path().join(".cursor")).expect("cursor dir"); + std::fs::create_dir(directory.path().join(".vscode")).expect("vscode dir"); + std::fs::write(directory.path().join(".cursor/mcp.json"), b"{secret") + .expect("malformed fixture"); + std::fs::write( + directory.path().join(".vscode/mcp.json"), + br#"{"servers":{"docs":{"url":"https://example.test"}}}"#, + ) + .expect("valid fixture"); + + let envelope = discover(&DiscoveryRequest { + include_default_paths: false, + effective_profile: None, + roots: vec![directory.path().canonicalize().expect("canonical root")], + }) + .expect("discovery"); + + assert_eq!(envelope.summary.sources, 2); + assert_eq!(envelope.summary.entries, 1); + assert!(envelope + .sources + .iter() + .any(|source| source.status == SourceStatus::Malformed)); + let serialized = serde_json::to_string(&envelope).expect("serialize"); + assert!(!serialized.contains("secret")); + } + + #[cfg(unix)] + #[test] + fn repeated_roots_stop_with_one_bounded_limit_diagnostic() { + let directory = tempfile::tempdir().expect("tempdir"); + let root = directory.path().canonicalize().expect("canonical root"); + + let envelope = discover(&DiscoveryRequest { + include_default_paths: false, + effective_profile: None, + roots: vec![root; 300], + }) + .expect("discovery"); + + assert_eq!(envelope.summary.sources, 1); + assert_eq!(envelope.summary.diagnostics, 1); + assert_eq!(envelope.sources[0].status, SourceStatus::LimitReached); + assert_eq!(envelope.diagnostics[0].code, DiagnosticCode::LimitReached); + assert_eq!( + envelope.sources[0].path_ref, + format!( + "$ROOT[{}]/.cursor/mcp.json", + super::super::MAX_DIRECTORIES_PER_INVOCATION / 2 + ) + ); + } +} diff --git a/src/discovery/mod.rs b/src/discovery/mod.rs index 0597ee5..bd0f3dd 100644 --- a/src/discovery/mod.rs +++ b/src/discovery/mod.rs @@ -1,8 +1,7 @@ -//! Crate-private registry and structural parsers for local client discovery. -//! -//! D.0 intentionally has no CLI or public API consumer. The module-level -//! allowance is removed when D.1 wires the accepted types into `discover`. -#![allow(dead_code)] +//! Binary-private registry, filesystem boundary, and structural parsers for +//! local client discovery. + +mod filesystem; use serde::Serialize; use serde_json::{Map, Value}; @@ -31,7 +30,7 @@ pub(crate) enum ClientId { } impl ClientId { - fn as_str(self) -> &'static str { + pub(crate) fn as_str(self) -> &'static str { match self { Self::ClaudeCode => "claude_code", Self::Cursor => "cursor", @@ -55,7 +54,7 @@ pub(crate) enum DiscoveryScope { } impl DiscoveryScope { - fn as_str(self) -> &'static str { + pub(crate) fn as_str(self) -> &'static str { match self { Self::User => "user", Self::Workspace => "workspace", @@ -149,6 +148,24 @@ pub(crate) enum EntryState { LocalReference, } +impl EntryState { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Configured => "configured", + Self::Disabled => "disabled", + Self::Unresolved => "unresolved", + Self::LocalReference => "local_reference", + } + } +} + +pub(crate) const ENTRY_STATES: &[EntryState] = &[ + EntryState::Configured, + EntryState::Disabled, + EntryState::Unresolved, + EntryState::LocalReference, +]; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum SupportStatus { @@ -167,6 +184,7 @@ pub(crate) enum DiagnosticCode { EntryNameTooLong, EntryNameInvalid, EntryLimitReached, + LimitReached, ConfigSizeLimitReached, PermissionDenied, UnsupportedFilesystemSafety, @@ -205,11 +223,13 @@ impl RedactedPathRef { Some(Self(value)) } - fn as_str(&self) -> &str { + pub(crate) fn as_str(&self) -> &str { &self.0 } } +pub(crate) use filesystem::{discover, DiscoveryRequest}; + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub(crate) struct DiscoverySource { pub source_id: String, diff --git a/src/lib.rs b/src/lib.rs index c29010a..a85f32c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,7 +19,6 @@ pub mod analysis; pub mod baseline; pub mod certify; pub mod config; -mod discovery; pub mod doctor; pub mod egress; pub mod error; diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs new file mode 100644 index 0000000..78f0bb4 --- /dev/null +++ b/tests/discovery_cli.rs @@ -0,0 +1,96 @@ +use std::process::Command; + +fn agentshield() -> Command { + Command::new(env!("CARGO_BIN_EXE_agentshield")) +} + +#[test] +fn discover_json_is_versioned_and_does_not_leak_config_values() { + let root = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(root.path().join(".cursor")).expect("cursor directory"); + std::fs::write( + root.path().join(".cursor/mcp.json"), + br#"{"mcpServers":{"local":{"command":"node","args":["--token","secret"]}}}"#, + ) + .expect("config"); + let root = root.path().canonicalize().expect("canonical root"); + + let output = agentshield() + .args(["discover", "--no-default-paths", "--root"]) + .arg(&root) + .args(["--format", "json"]) + .output() + .expect("run discover"); + assert!(output.status.success(), "{output:?}"); + + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let envelope: serde_json::Value = serde_json::from_str(&stdout).expect("json output"); + assert_eq!(envelope["schema"], "agentshield.discovery/v1"); + assert_eq!(envelope["registry_version"], 1); + #[cfg(unix)] + { + assert_eq!(envelope["summary"]["sources"], 1); + assert_eq!(envelope["summary"]["entries"], 1); + } + #[cfg(not(unix))] + assert_eq!( + envelope["sources"][0]["status"], + "unsupported_filesystem_safety" + ); + + assert!(!stdout.contains("secret")); + assert!(!stdout.contains("node")); + assert!(!stdout.contains(root.to_string_lossy().as_ref())); +} + +#[test] +fn discover_no_default_paths_with_no_roots_is_empty() { + let output = agentshield() + .args(["discover", "--no-default-paths", "--format", "json"]) + .env("HOME", "/this/path/must/not/be/read") + .output() + .expect("run discover"); + assert!(output.status.success(), "{output:?}"); + + let envelope: serde_json::Value = serde_json::from_slice(&output.stdout).expect("json output"); + assert_eq!(envelope["summary"]["sources"], 0); + assert_eq!(envelope["summary"]["entries"], 0); + assert_eq!(envelope["summary"]["diagnostics"], 0); +} + +#[test] +fn discover_explain_redacts_root_and_keeps_json_clean() { + let root = tempfile::tempdir().expect("tempdir"); + let root = root.path().canonicalize().expect("canonical root"); + let output = agentshield() + .args(["discover", "--no-default-paths", "--root"]) + .arg(&root) + .args(["--format", "json", "--explain"]) + .output() + .expect("run discover"); + assert!(output.status.success(), "{output:?}"); + serde_json::from_slice::(&output.stdout).expect("stdout remains JSON"); + + let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); + assert!(stderr.contains("$ROOT[0]")); + assert!(stderr.contains("never followed or read")); + assert!(!stderr.contains(root.to_string_lossy().as_ref())); +} + +#[test] +fn discover_rejects_invalid_format_and_parent_root() { + let invalid_format = agentshield() + .args(["discover", "--no-default-paths", "--format", "sarif"]) + .output() + .expect("run discover"); + assert_eq!(invalid_format.status.code(), Some(2)); + + let invalid_root = agentshield() + .args(["discover", "--no-default-paths", "--root", "../private"]) + .output() + .expect("run discover"); + assert_eq!(invalid_root.status.code(), Some(2)); + let stderr = String::from_utf8(invalid_root.stderr).expect("utf8 stderr"); + assert!(stderr.contains("root[0]")); + assert!(!stderr.contains("private")); +} From 1bb25b92af01e1bcb5ab869e4de9c4c12f1b1e30 Mon Sep 17 00:00:00 2001 From: Ronaldo Martins Date: Fri, 24 Jul 2026 00:18:07 -0300 Subject: [PATCH 2/2] fix(cli): keep discovery schema live on Windows --- src/bin/cli/discover.rs | 23 ++++++++++++++++++----- src/discovery/filesystem.rs | 8 ++++++-- src/discovery/mod.rs | 25 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/bin/cli/discover.rs b/src/bin/cli/discover.rs index bad5d6a..15eb261 100644 --- a/src/bin/cli/discover.rs +++ b/src/bin/cli/discover.rs @@ -3,10 +3,11 @@ use std::path::PathBuf; use agentshield::error::{Result, ShieldError}; use crate::discovery::{ - discover, registry, DiscoveryBase, DiscoveryEnvelope, DiscoveryRequest, ENTRY_STATES, - MAX_AGGREGATE_BYTES, MAX_CANDIDATE_FILES_PER_INVOCATION, MAX_CANDIDATE_FILES_PER_ROOT, - MAX_CONFIG_BYTES, MAX_DEPTH_PER_ROOT, MAX_DIRECTORIES_PER_INVOCATION, MAX_DIRECTORIES_PER_ROOT, - MAX_ENTRIES_PER_INVOCATION, MAX_OPENED_CONFIGS_PER_INVOCATION, MAX_OPENED_CONFIGS_PER_ROOT, + discover, registry, DiscoveryBase, DiscoveryEnvelope, DiscoveryRequest, DIAGNOSTIC_CODES, + ENTRY_STATES, MAX_AGGREGATE_BYTES, MAX_CANDIDATE_FILES_PER_INVOCATION, + MAX_CANDIDATE_FILES_PER_ROOT, MAX_CONFIG_BYTES, MAX_DEPTH_PER_ROOT, + MAX_DIRECTORIES_PER_INVOCATION, MAX_DIRECTORIES_PER_ROOT, MAX_ENTRIES_PER_INVOCATION, + MAX_OPENED_CONFIGS_PER_INVOCATION, MAX_OPENED_CONFIGS_PER_ROOT, SOURCE_STATUSES, }; pub(crate) fn cmd_discover( @@ -75,6 +76,16 @@ fn render_explanation(request: &DiscoveryRequest) -> String { .map(|state| state.as_str()) .collect::>() .join(", "); + let source_statuses = SOURCE_STATUSES + .iter() + .map(json_label) + .collect::>() + .join(", "); + let diagnostic_codes = DIAGNOSTIC_CODES + .iter() + .map(json_label) + .collect::>() + .join(", "); format!( "Discovery plan (read-only; no scan or execution)\n\ @@ -87,7 +98,9 @@ fn render_explanation(request: &DiscoveryRequest) -> String { aggregate_bytes={MAX_AGGREGATE_BYTES}, entries={MAX_ENTRIES_PER_INVOCATION}\n\ Symlinks and special files are never followed or read. Platforms without an atomic \ no-follow primitive fail closed.\n\ - Entry states: {entry_states}.\n", + Source statuses: {source_statuses}.\n\ + Entry states: {entry_states}.\n\ + Diagnostic codes: {diagnostic_codes}.\n", if request.include_default_paths { "enabled" } else { diff --git a/src/discovery/filesystem.rs b/src/discovery/filesystem.rs index d42b462..af0c5bd 100644 --- a/src/discovery/filesystem.rs +++ b/src/discovery/filesystem.rs @@ -570,7 +570,11 @@ mod platform { let mut parsed = Vec::new(); for descriptor in registry() { let root_indices: Vec> = match descriptor.base { - DiscoveryBase::EffectiveProfile if request.include_default_paths => vec![None], + DiscoveryBase::EffectiveProfile + if request.include_default_paths && request.effective_profile.is_some() => + { + vec![None] + } DiscoveryBase::ExplicitRoot => (0..request.roots.len()).map(Some).collect(), _ => Vec::new(), }; @@ -588,7 +592,7 @@ mod platform { } } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; diff --git a/src/discovery/mod.rs b/src/discovery/mod.rs index bd0f3dd..78cbf9e 100644 --- a/src/discovery/mod.rs +++ b/src/discovery/mod.rs @@ -139,6 +139,16 @@ pub(crate) enum SourceStatus { ChangeDetectedDuringRead, } +pub(crate) const SOURCE_STATUSES: &[SourceStatus] = &[ + SourceStatus::Inspected, + SourceStatus::Unsupported, + SourceStatus::Malformed, + SourceStatus::PermissionDenied, + SourceStatus::LimitReached, + SourceStatus::UnsupportedFilesystemSafety, + SourceStatus::ChangeDetectedDuringRead, +]; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum EntryState { @@ -191,6 +201,21 @@ pub(crate) enum DiagnosticCode { ChangeDetectedDuringRead, } +pub(crate) const DIAGNOSTIC_CODES: &[DiagnosticCode] = &[ + DiagnosticCode::InvalidJson, + DiagnosticCode::MissingServerMap, + DiagnosticCode::ServerMapNotObject, + DiagnosticCode::EntryNotObject, + DiagnosticCode::EntryNameTooLong, + DiagnosticCode::EntryNameInvalid, + DiagnosticCode::EntryLimitReached, + DiagnosticCode::LimitReached, + DiagnosticCode::ConfigSizeLimitReached, + DiagnosticCode::PermissionDenied, + DiagnosticCode::UnsupportedFilesystemSafety, + DiagnosticCode::ChangeDetectedDuringRead, +]; + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum DiscoveryMethod {