use mcp_execution_core::metadata::{METADATA_FILE_NAME, METADATA_SCHEMA_VERSION, ServerMetadata};
use regex::Regex;
use serde::Deserialize;
use std::path::Path;
use std::sync::LazyLock;
use thiserror::Error;
pub const MAX_TOOL_FILES: usize = 500;
pub const MAX_FILE_SIZE: u64 = 1024 * 1024;
pub const MAX_FRONTMATTER_SIZE: usize = 8 * 1024;
static FRONTMATTER_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^---\s*\n([\s\S]*?)\n---").expect("valid regex"));
use mcp_execution_core::sanitize_path_for_error;
#[derive(Debug, Error)]
pub enum ScanError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("directory does not exist: {path}")]
DirectoryNotFound {
path: String,
},
#[error("metadata sidecar not found: {path} (was the server directory regenerated?)")]
MissingMetadata {
path: String,
},
#[error("failed to parse metadata sidecar {path}: {source}")]
MetadataParse {
path: String,
#[source]
source: serde_json::Error,
},
#[error("unsupported metadata schema version: found {found}, expected {expected}")]
UnsupportedSchema {
found: u32,
expected: u32,
},
#[error("too many tools: {count} exceeds limit of {limit}")]
TooManyFiles {
count: usize,
limit: usize,
},
#[error("file too large: {path} ({size} bytes exceeds {limit} limit)")]
FileTooLarge {
path: String,
size: u64,
limit: u64,
},
#[error(
"stale metadata: tool '{tool}' is listed in {sidecar_path} but its file '{expected_file}' \
is missing (re-run 'generate' to regenerate this server)"
)]
StaleMetadata {
tool: String,
expected_file: String,
sidecar_path: String,
},
}
#[derive(Debug, Clone, Default)]
pub struct ScanResult {
pub tools: Vec<ParsedToolFile>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ParsedToolFile {
pub name: String,
pub typescript_name: String,
pub server_id: String,
pub category: Option<String>,
pub keywords: Vec<String>,
pub description: Option<String>,
pub parameters: Vec<ParsedParameter>,
}
#[derive(Debug, Clone)]
pub struct ParsedParameter {
pub name: String,
pub typescript_type: String,
pub required: bool,
pub description: Option<String>,
}
impl From<mcp_execution_core::metadata::ParameterMetadata> for ParsedParameter {
fn from(meta: mcp_execution_core::metadata::ParameterMetadata) -> Self {
Self {
name: meta.name,
typescript_type: meta.typescript_type,
required: meta.required,
description: meta.description,
}
}
}
fn parsed_tool_file_from_metadata(
meta: mcp_execution_core::metadata::ToolMetadata,
server_id: &str,
) -> ParsedToolFile {
ParsedToolFile {
name: meta.name.into_inner(),
typescript_name: meta.typescript_name,
server_id: server_id.to_string(),
category: meta.category,
keywords: meta.keywords,
description: meta.description,
parameters: meta.parameters.into_iter().map(Into::into).collect(),
}
}
pub async fn scan_tools_directory(dir: &Path) -> Result<ScanResult, ScanError> {
let canonical_base = tokio::fs::canonicalize(dir).await.map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
ScanError::DirectoryNotFound {
path: sanitize_path_for_error(dir),
}
} else {
ScanError::Io(err)
}
})?;
let meta_path = canonical_base.join(METADATA_FILE_NAME);
let canonical_meta = match tokio::fs::canonicalize(&meta_path).await {
Ok(path) if path.starts_with(&canonical_base) => path,
Ok(_) => {
return Err(ScanError::MissingMetadata {
path: sanitize_path_for_error(&meta_path),
});
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(ScanError::MissingMetadata {
path: sanitize_path_for_error(&meta_path),
});
}
Err(err) => return Err(ScanError::Io(err)),
};
let file_metadata = tokio::fs::metadata(&canonical_meta).await?;
if file_metadata.len() > MAX_FILE_SIZE {
return Err(ScanError::FileTooLarge {
path: sanitize_path_for_error(&meta_path),
size: file_metadata.len(),
limit: MAX_FILE_SIZE,
});
}
let content = tokio::fs::read_to_string(&canonical_meta).await?;
let meta: ServerMetadata =
serde_json::from_str(&content).map_err(|source| ScanError::MetadataParse {
path: sanitize_path_for_error(&meta_path),
source,
})?;
if meta.schema_version != METADATA_SCHEMA_VERSION {
return Err(ScanError::UnsupportedSchema {
found: meta.schema_version,
expected: METADATA_SCHEMA_VERSION,
});
}
if meta.tools.len() > MAX_TOOL_FILES {
return Err(ScanError::TooManyFiles {
count: meta.tools.len(),
limit: MAX_TOOL_FILES,
});
}
let warnings = verify_tool_files_on_disk(&canonical_base, &meta.tools, &meta_path).await?;
let server_id = meta.server_id.into_inner();
let mut tools: Vec<ParsedToolFile> = meta
.tools
.into_iter()
.map(|tool| parsed_tool_file_from_metadata(tool, &server_id))
.collect();
tools.sort_by(|a, b| a.name.cmp(&b.name));
Ok(ScanResult { tools, warnings })
}
async fn verify_tool_files_on_disk(
dir: &Path,
tools: &[mcp_execution_core::metadata::ToolMetadata],
meta_path: &Path,
) -> Result<Vec<String>, ScanError> {
const INDEX_FILE_NAME: &str = "index.ts";
let mut expected_files: std::collections::HashSet<String> =
std::collections::HashSet::with_capacity(tools.len());
for tool in tools {
let file_name = format!("{}.ts", tool.typescript_name);
if !dir.join(&file_name).is_file() {
return Err(ScanError::StaleMetadata {
tool: tool.name.to_string(),
expected_file: file_name,
sidecar_path: sanitize_path_for_error(meta_path),
});
}
expected_files.insert(file_name);
}
let mut warnings = Vec::new();
let mut entries = tokio::fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(std::ffi::OsStr::to_str) != Some("ts") {
continue;
}
let Some(file_name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
continue;
};
if file_name == INDEX_FILE_NAME || expected_files.contains(file_name) {
continue;
}
tracing::warn!(
file = %file_name,
"found .ts tool file not referenced by _meta.json; it will be omitted from SKILL.md \
(re-run 'generate' to refresh the sidecar)"
);
warnings.push(format!(
"'{file_name}' is not referenced by _meta.json and was excluded from SKILL.md \
(re-run 'generate' to refresh the sidecar)"
));
}
Ok(warnings)
}
#[derive(Debug, Error)]
pub enum SkillMetadataError {
#[error("YAML frontmatter not found")]
MissingFrontmatter,
#[error("YAML frontmatter too large: {size} bytes exceeds {limit} limit")]
FrontmatterTooLarge {
size: usize,
limit: usize,
},
#[error("failed to parse YAML frontmatter: {0}")]
InvalidYaml(String),
#[error("'{field}' field is missing or empty in frontmatter")]
MissingField {
field: &'static str,
},
}
fn describe_yaml_error(err: &serde_norway::Error) -> String {
let rendered = err.to_string();
let Some(location) = err.location() else {
return rendered;
};
let block_relative = format!("line {} column {}", location.line(), location.column());
let file_relative = format!("line {} column {}", location.line() + 1, location.column());
rendered.replacen(&block_relative, &file_relative, 1)
}
#[derive(Debug, Deserialize)]
struct RawFrontmatter {
name: Option<String>,
description: Option<String>,
}
fn require_field(value: Option<String>, field: &'static str) -> Result<String, SkillMetadataError> {
match value {
Some(v) if !v.trim().is_empty() => Ok(v),
_ => Err(SkillMetadataError::MissingField { field }),
}
}
pub fn extract_skill_metadata(
content: &str,
) -> Result<crate::types::SkillMetadata, SkillMetadataError> {
use crate::types::SkillMetadata;
let frontmatter_block = FRONTMATTER_REGEX
.captures(content)
.and_then(|c| c.get(1))
.map(|m| m.as_str())
.ok_or(SkillMetadataError::MissingFrontmatter)?;
if frontmatter_block.len() > MAX_FRONTMATTER_SIZE {
return Err(SkillMetadataError::FrontmatterTooLarge {
size: frontmatter_block.len(),
limit: MAX_FRONTMATTER_SIZE,
});
}
let frontmatter: RawFrontmatter = serde_norway::from_str(frontmatter_block)
.map_err(|e| SkillMetadataError::InvalidYaml(describe_yaml_error(&e)))?;
let name = require_field(frontmatter.name, "name")?;
let description = require_field(frontmatter.description, "description")?;
let section_count = content.lines().filter(|l| l.starts_with("## ")).count();
let word_count = content.split_whitespace().count();
Ok(SkillMetadata {
name,
description,
section_count,
word_count,
})
}
#[cfg(test)]
mod tests {
use super::*;
use mcp_execution_core::metadata::{ParameterMetadata, ToolMetadata};
use mcp_execution_core::{ServerId, ToolName};
use tempfile::TempDir;
fn sample_metadata(tool_count: usize) -> ServerMetadata {
ServerMetadata {
schema_version: METADATA_SCHEMA_VERSION,
server_id: ServerId::new("github").unwrap(),
server_name: "GitHub".to_string(),
server_version: "1.0.0".to_string(),
tools: (0..tool_count)
.map(|i| ToolMetadata {
name: ToolName::new(format!("tool_{i}")).unwrap(),
typescript_name: format!("tool{i}"),
category: Some("test".to_string()),
keywords: vec!["test".to_string()],
description: Some(format!("Tool {i}")),
parameters: vec![ParameterMetadata {
name: "param".to_string(),
typescript_type: "string".to_string(),
required: true,
description: Some("A parameter".to_string()),
}],
})
.collect(),
}
}
async fn write_metadata(dir: &Path, meta: &ServerMetadata) {
let content = serde_json::to_string_pretty(meta).unwrap();
tokio::fs::write(dir.join(METADATA_FILE_NAME), content)
.await
.unwrap();
for tool in &meta.tools {
tokio::fs::write(
dir.join(format!("{}.ts", tool.typescript_name)),
"export {}",
)
.await
.unwrap();
}
}
#[tokio::test]
async fn test_scan_tools_directory_round_trip_preserves_parameter_descriptions() {
let temp_dir = TempDir::new().unwrap();
let meta = sample_metadata(2);
write_metadata(temp_dir.path(), &meta).await;
let result = scan_tools_directory(temp_dir.path()).await.unwrap();
let tools = result.tools;
assert_eq!(tools.len(), 2);
assert_eq!(tools[0].name, "tool_0");
assert_eq!(tools[0].server_id, "github");
assert_eq!(tools[0].parameters.len(), 1);
assert_eq!(
tools[0].parameters[0].description,
Some("A parameter".to_string()),
"parameter descriptions must survive the sidecar round-trip"
);
assert!(result.warnings.is_empty());
}
#[tokio::test]
async fn test_scan_tools_directory_sorts_by_name() {
let temp_dir = TempDir::new().unwrap();
let mut meta = sample_metadata(0);
meta.tools = vec![
ToolMetadata {
name: ToolName::new("zebra").unwrap(),
typescript_name: "zebra".to_string(),
category: None,
keywords: vec![],
description: None,
parameters: vec![],
},
ToolMetadata {
name: ToolName::new("alpha").unwrap(),
typescript_name: "alpha".to_string(),
category: None,
keywords: vec![],
description: None,
parameters: vec![],
},
];
write_metadata(temp_dir.path(), &meta).await;
let tools = scan_tools_directory(temp_dir.path()).await.unwrap().tools;
assert_eq!(tools[0].name, "alpha");
assert_eq!(tools[1].name, "zebra");
}
#[tokio::test]
async fn test_scan_tools_directory_stale_metadata_missing_ts_file() {
let temp_dir = TempDir::new().unwrap();
let meta = sample_metadata(1);
let content = serde_json::to_string_pretty(&meta).unwrap();
tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), content)
.await
.unwrap();
let result = scan_tools_directory(temp_dir.path()).await;
match result {
Err(ScanError::StaleMetadata {
tool,
expected_file,
..
}) => {
assert_eq!(tool, "tool_0");
assert_eq!(expected_file, "tool0.ts");
}
other => panic!("expected StaleMetadata, got: {other:?}"),
}
}
#[tokio::test]
async fn test_scan_tools_directory_stale_metadata_reports_first_missing_in_sidecar_order() {
let temp_dir = TempDir::new().unwrap();
let meta = sample_metadata(3);
let content = serde_json::to_string_pretty(&meta).unwrap();
tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), content)
.await
.unwrap();
tokio::fs::write(temp_dir.path().join("tool1.ts"), "export {}")
.await
.unwrap();
let result = scan_tools_directory(temp_dir.path()).await;
match result {
Err(ScanError::StaleMetadata {
tool,
expected_file,
..
}) => {
assert_eq!(tool, "tool_0");
assert_eq!(expected_file, "tool0.ts");
}
other => panic!("expected StaleMetadata for tool_0, got: {other:?}"),
}
}
#[tokio::test]
async fn test_scan_tools_directory_extra_ts_file_excluded_from_result() {
let temp_dir = TempDir::new().unwrap();
let meta = sample_metadata(1);
write_metadata(temp_dir.path(), &meta).await;
tokio::fs::write(temp_dir.path().join("orphan.ts"), "export {}")
.await
.unwrap();
let result = scan_tools_directory(temp_dir.path()).await.unwrap();
assert_eq!(
result.tools.len(),
1,
"the orphaned .ts file must not be reported as a tool"
);
assert_eq!(result.tools[0].name, "tool_0");
assert_eq!(
result.warnings.len(),
1,
"the orphaned .ts file must be surfaced as a warning"
);
assert!(
result.warnings[0].contains("orphan.ts"),
"warning must name the excluded file: {:?}",
result.warnings[0]
);
}
#[tokio::test]
async fn test_scan_tools_directory_index_ts_not_treated_as_extra() {
let temp_dir = TempDir::new().unwrap();
let meta = sample_metadata(1);
write_metadata(temp_dir.path(), &meta).await;
tokio::fs::write(temp_dir.path().join("index.ts"), "export * from './tool0';")
.await
.unwrap();
let result = scan_tools_directory(temp_dir.path()).await.unwrap();
assert_eq!(result.tools.len(), 1);
assert_eq!(result.tools[0].name, "tool_0");
assert!(
result.warnings.is_empty(),
"index.ts must not be reported as a warning"
);
}
#[test]
fn test_stale_metadata_error_message_tells_user_to_regenerate() {
let err = ScanError::StaleMetadata {
tool: "create_issue".to_string(),
expected_file: "createIssue.ts".to_string(),
sidecar_path: "~/.claude/servers/github/_meta.json".to_string(),
};
let message = err.to_string();
assert!(
message.contains("create_issue"),
"message must name the affected tool"
);
assert!(
message.contains("createIssue.ts"),
"message must name the missing file"
);
assert!(
message.contains("re-run 'generate'"),
"message must tell the user how to fix it: {message}"
);
}
#[tokio::test]
async fn test_scan_tools_directory_missing_metadata() {
let temp_dir = TempDir::new().unwrap();
let result = scan_tools_directory(temp_dir.path()).await;
assert!(matches!(result, Err(ScanError::MissingMetadata { .. })));
}
#[tokio::test]
async fn test_scan_tools_directory_corrupt_json() {
let temp_dir = TempDir::new().unwrap();
tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), "{not valid json")
.await
.unwrap();
let result = scan_tools_directory(temp_dir.path()).await;
assert!(matches!(result, Err(ScanError::MetadataParse { .. })));
}
#[tokio::test]
async fn test_scan_tools_directory_rejects_invalid_server_id_in_valid_json() {
let temp_dir = TempDir::new().unwrap();
let json = r#"{
"schema_version": 1,
"server_id": "not/a/valid/id",
"server_name": "GitHub",
"server_version": "1.0.0",
"tools": []
}"#;
tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), json)
.await
.unwrap();
let result = scan_tools_directory(temp_dir.path()).await;
assert!(matches!(result, Err(ScanError::MetadataParse { .. })));
}
#[tokio::test]
async fn test_scan_tools_directory_rejects_invalid_tool_name_in_valid_json() {
let temp_dir = TempDir::new().unwrap();
let json = r#"{
"schema_version": 1,
"server_id": "github",
"server_name": "GitHub",
"server_version": "1.0.0",
"tools": [{
"name": "../escape",
"typescript_name": "escape",
"category": null,
"keywords": [],
"description": null,
"parameters": []
}]
}"#;
tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), json)
.await
.unwrap();
let result = scan_tools_directory(temp_dir.path()).await;
assert!(matches!(result, Err(ScanError::MetadataParse { .. })));
}
#[tokio::test]
async fn test_scan_tools_directory_unsupported_schema() {
let temp_dir = TempDir::new().unwrap();
let mut meta = sample_metadata(1);
meta.schema_version = METADATA_SCHEMA_VERSION + 1;
write_metadata(temp_dir.path(), &meta).await;
let result = scan_tools_directory(temp_dir.path()).await;
match result {
Err(ScanError::UnsupportedSchema { found, expected }) => {
assert_eq!(found, METADATA_SCHEMA_VERSION + 1);
assert_eq!(expected, METADATA_SCHEMA_VERSION);
}
other => panic!("expected UnsupportedSchema, got: {other:?}"),
}
}
#[tokio::test]
async fn test_scan_tools_directory_too_many_tools() {
let temp_dir = TempDir::new().unwrap();
let meta = sample_metadata(MAX_TOOL_FILES + 1);
write_metadata(temp_dir.path(), &meta).await;
let result = scan_tools_directory(temp_dir.path()).await;
match result {
Err(ScanError::TooManyFiles { count, limit }) => {
assert_eq!(count, MAX_TOOL_FILES + 1);
assert_eq!(limit, MAX_TOOL_FILES);
}
other => panic!("expected TooManyFiles, got: {other:?}"),
}
}
#[tokio::test]
async fn test_scan_tools_directory_file_too_large() {
let temp_dir = TempDir::new().unwrap();
let mut meta = sample_metadata(1);
#[allow(clippy::cast_possible_truncation)]
let padding = "a".repeat((MAX_FILE_SIZE as usize) + 1);
meta.tools[0].description = Some(padding);
write_metadata(temp_dir.path(), &meta).await;
let result = scan_tools_directory(temp_dir.path()).await;
match result {
Err(ScanError::FileTooLarge { size, limit, .. }) => {
assert!(size > MAX_FILE_SIZE);
assert_eq!(limit, MAX_FILE_SIZE);
}
other => panic!("expected FileTooLarge, got: {other:?}"),
}
}
#[tokio::test]
async fn test_scan_tools_directory_nonexistent() {
let result = scan_tools_directory(Path::new("/nonexistent/path/for/testing")).await;
assert!(matches!(result, Err(ScanError::DirectoryNotFound { .. })));
}
#[tokio::test]
#[cfg(unix)]
async fn test_scan_tools_directory_canonicalize_non_not_found_error_propagates_as_io() {
let temp_dir = TempDir::new().unwrap();
let loop_path = temp_dir.path().join("loop");
std::os::unix::fs::symlink(&loop_path, &loop_path).unwrap();
let result = scan_tools_directory(&loop_path).await;
match result {
Err(ScanError::Io(err)) => {
assert_ne!(err.kind(), std::io::ErrorKind::NotFound);
}
other => panic!("expected ScanError::Io, got: {other:?}"),
}
}
#[test]
fn test_extract_skill_metadata_valid() {
let content = r"---
name: github-progressive
description: GitHub MCP server operations
---
# GitHub Progressive
## Quick Start
Content here.
## Common Tasks
More content.
";
let result = extract_skill_metadata(content);
assert!(result.is_ok());
let metadata = result.unwrap();
assert_eq!(metadata.name, "github-progressive");
assert_eq!(metadata.description, "GitHub MCP server operations");
assert_eq!(metadata.section_count, 2);
assert!(metadata.word_count > 0);
}
#[test]
fn test_extract_skill_metadata_no_frontmatter() {
let content = "# Test\n\nNo frontmatter";
let result = extract_skill_metadata(content);
assert!(matches!(
result,
Err(SkillMetadataError::MissingFrontmatter)
));
}
#[test]
fn test_extract_skill_metadata_missing_name() {
let content = "---\ndescription: test\n---\n# Test";
let result = extract_skill_metadata(content);
assert!(matches!(
result,
Err(SkillMetadataError::MissingField { field: "name" })
));
}
#[test]
fn test_extract_skill_metadata_missing_description() {
let content = "---\nname: test\n---\n# Test";
let result = extract_skill_metadata(content);
assert!(matches!(
result,
Err(SkillMetadataError::MissingField {
field: "description"
})
));
}
#[test]
fn test_extract_skill_metadata_invalid_yaml() {
let content = "---\nname: [unterminated\ndescription: test\n---\n# Test";
let result = extract_skill_metadata(content);
let Err(SkillMetadataError::InvalidYaml(message)) = &result else {
panic!("expected InvalidYaml, got: {result:?}");
};
assert!(
message.contains("line 2"),
"expected file-relative 'line 2', got: {message:?}"
);
}
#[test]
fn test_extract_skill_metadata_frontmatter_too_large() {
let padding = "a".repeat(MAX_FRONTMATTER_SIZE + 1);
let content = format!("---\nname: test\ndescription: {padding}\n---\n# Test");
let result = extract_skill_metadata(&content);
match result {
Err(SkillMetadataError::FrontmatterTooLarge { size, limit }) => {
assert!(size > MAX_FRONTMATTER_SIZE);
assert_eq!(limit, MAX_FRONTMATTER_SIZE);
}
other => panic!("expected FrontmatterTooLarge, got: {other:?}"),
}
}
#[test]
fn test_extract_skill_metadata_null_name_rejected() {
let content = "---\nname: ~\ndescription: test\n---\n# Test";
let result = extract_skill_metadata(content);
assert!(matches!(
result,
Err(SkillMetadataError::MissingField { field: "name" })
));
}
#[test]
fn test_extract_skill_metadata_empty_string_name_rejected() {
let content = "---\nname: \"\"\ndescription: test\n---\n# Test";
let result = extract_skill_metadata(content);
assert!(matches!(
result,
Err(SkillMetadataError::MissingField { field: "name" })
));
}
#[test]
fn test_extract_skill_metadata_with_extra_fields() {
let content = r"---
name: test-skill
description: Test description
version: 1.0.0
author: Test Author
---
# Test
";
let result = extract_skill_metadata(content);
assert!(result.is_ok());
let metadata = result.unwrap();
assert_eq!(metadata.name, "test-skill");
assert_eq!(metadata.description, "Test description");
}
#[test]
fn test_extract_skill_metadata_block_literal_scalar() {
let content = r"---
name: test-skill
description: |
This is a block literal description
spanning multiple lines.
---
# Test
";
let metadata = extract_skill_metadata(content).unwrap();
assert_ne!(metadata.description, "|");
assert!(metadata.description.contains("block literal description"));
assert!(metadata.description.contains("spanning multiple lines."));
}
#[test]
fn test_extract_skill_metadata_folded_block_scalar() {
let content = r"---
name: test-skill
description: >
This is a folded description
spanning multiple lines.
---
# Test
";
let metadata = extract_skill_metadata(content).unwrap();
assert_ne!(metadata.description, ">");
assert!(metadata.description.contains("folded description"));
assert!(metadata.description.contains("spanning multiple lines."));
}
#[test]
fn test_extract_skill_metadata_quoted_scalars() {
let content = r#"---
name: "quoted-name"
description: 'quoted text'
---
# Test
"#;
let metadata = extract_skill_metadata(content).unwrap();
assert_eq!(metadata.name, "quoted-name");
assert_eq!(metadata.description, "quoted text");
}
fn alias_bomb_fixture(preamble: &str) -> String {
use std::fmt::Write as _;
let mut frontmatter = String::from(preamble);
writeln!(frontmatter, " - &a0 [x, x, x, x, x, x, x, x]").unwrap();
for level in 1..=7 {
let prev = level - 1;
let refs = (0..8)
.map(|_| format!("*a{prev}"))
.collect::<Vec<_>>()
.join(", ");
writeln!(frontmatter, " - &a{level} [{refs}]").unwrap();
}
writeln!(frontmatter, " - *a7").unwrap();
frontmatter
}
#[test]
fn test_extract_skill_metadata_alias_bomb_under_unknown_key_stays_ok() {
use std::time::{Duration, Instant};
let frontmatter =
alias_bomb_fixture("name: test-skill\ndescription: valid description\nunknown_key:\n");
let content = format!("---\n{frontmatter}---\n# Test\n");
assert!(
content.len() <= MAX_FRONTMATTER_SIZE,
"fixture must stay under the frontmatter cap to exercise the parser, not the size guard"
);
let start = Instant::now();
let result = extract_skill_metadata(&content);
let elapsed = start.elapsed();
assert!(
result.is_ok(),
"expected Ok: an alias bomb under a key RawFrontmatter does not declare must be \
ignored today without expansion; if this now errors, RawFrontmatter's field shape \
likely changed (e.g. a #[serde(flatten)] field) and reopened the amplification path \
serde_norway's own repetition-limit guard then catches at ms-scale cost instead of \
being ignored at us-scale cost. got: {result:?}"
);
assert!(
elapsed < Duration::from_secs(1),
"parse took {elapsed:?}, unexpectedly long even accounting for cold-process noise"
);
}
#[test]
fn test_serde_norway_buffers_alias_bomb_when_declared_field_is_value_typed() {
use std::time::{Duration, Instant};
#[derive(Debug, Deserialize)]
#[allow(
dead_code,
reason = "fields exist only to mirror RawFrontmatter's shape"
)]
struct RawFrontmatterBufferedDescription {
name: Option<String>,
description: serde_norway::Value,
}
let frontmatter = alias_bomb_fixture("name: test-skill\ndescription:\n");
assert!(
frontmatter.len() <= MAX_FRONTMATTER_SIZE,
"fixture unexpectedly exceeds MAX_FRONTMATTER_SIZE; re-check alias_bomb_fixture's margin"
);
let start = Instant::now();
let result = serde_norway::from_str::<RawFrontmatterBufferedDescription>(&frontmatter);
let elapsed = start.elapsed();
let err = result.expect_err(
"expected Err: buffering a declared field into serde_norway::Value forces alias \
expansion before per-field routing, which should trip serde_norway's own \
repetition-limit guard",
);
assert!(
err.to_string().contains("repetition limit exceeded"),
"expected serde_norway's own repetition-limit guard specifically, not some \
unrelated error a future serde_norway version might introduce instead; got: {err}"
);
assert!(
elapsed < Duration::from_secs(1),
"parse took {elapsed:?}, unexpectedly long even accounting for cold-process noise"
);
}
#[test]
fn test_serde_norway_buffers_alias_bomb_when_declared_field_uses_deserialize_with() {
use std::time::{Duration, Instant};
fn buffer_via_value<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_norway::Value::deserialize(deserializer)?;
Ok(value.as_str().map(str::to_string))
}
#[derive(Debug, Deserialize)]
#[allow(
dead_code,
reason = "fields exist only to mirror RawFrontmatter's shape"
)]
struct RawFrontmatterDeserializeWithDescription {
name: Option<String>,
#[serde(deserialize_with = "buffer_via_value")]
description: Option<String>,
}
let frontmatter = alias_bomb_fixture("name: test-skill\ndescription:\n");
assert!(
frontmatter.len() <= MAX_FRONTMATTER_SIZE,
"fixture unexpectedly exceeds MAX_FRONTMATTER_SIZE; re-check alias_bomb_fixture's margin"
);
let start = Instant::now();
let result =
serde_norway::from_str::<RawFrontmatterDeserializeWithDescription>(&frontmatter);
let elapsed = start.elapsed();
let err = result.expect_err(
"expected Err: a buffering deserialize_with should force alias expansion before \
per-field routing just like an outright Value-typed field, even though the \
declared Rust type here stays Option<String>",
);
assert!(
err.to_string().contains("repetition limit exceeded"),
"expected serde_norway's own repetition-limit guard specifically, not some \
unrelated error a future serde_norway version might introduce instead; got: {err}"
);
assert!(
elapsed < Duration::from_secs(1),
"parse took {elapsed:?}, unexpectedly long even accounting for cold-process noise"
);
}
#[test]
fn test_extract_skill_metadata_alias_bomb_under_declared_field_short_circuits() {
let content = format!(
"---\n{}---\n# Test\n",
alias_bomb_fixture("name: test-skill\ndescription:\n")
);
assert!(
content.len() <= MAX_FRONTMATTER_SIZE,
"fixture must stay under the frontmatter cap to exercise the parser, not the size guard"
);
let result = extract_skill_metadata(&content);
let Err(SkillMetadataError::InvalidYaml(message)) = &result else {
panic!("expected InvalidYaml, got: {result:?}");
};
assert!(
!message.contains("repetition limit exceeded"),
"RawFrontmatter::description now trips serde_norway's repetition-limit guard on \
this fixture, meaning it was retyped to a buffering shape (serde_norway::Value, an \
untagged enum, or a buffering deserialize_with) without extending this test — see \
RawFrontmatter's doc comment. got: {message:?}"
);
}
}