1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{Result, TuffError};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum CapabilityType {
10 Skill,
11 Tool,
12 Hook,
13 Workflow,
14 Policy,
15 #[serde(rename = "mcp-server")]
19 McpServer,
20}
21
22impl CapabilityType {
23 pub fn plural_dir(&self) -> &'static str {
24 match self {
25 Self::Skill => "skills",
26 Self::Tool => "tools",
27 Self::Hook => "hooks",
28 Self::Workflow => "workflows",
29 Self::Policy => "policies",
30 Self::McpServer => "mcp-servers",
31 }
32 }
33
34 pub fn as_str(&self) -> &'static str {
35 match self {
36 Self::Skill => "skill",
37 Self::Tool => "tool",
38 Self::Hook => "hook",
39 Self::Workflow => "workflow",
40 Self::Policy => "policy",
41 Self::McpServer => "mcp-server",
42 }
43 }
44
45 pub fn parse(s: &str) -> Option<Self> {
46 match s {
47 "skill" => Some(Self::Skill),
48 "tool" => Some(Self::Tool),
49 "hook" => Some(Self::Hook),
50 "workflow" => Some(Self::Workflow),
51 "policy" => Some(Self::Policy),
52 "mcp-server" | "mcp" => Some(Self::McpServer),
53 _ => None,
54 }
55 }
56}
57
58impl std::fmt::Display for CapabilityType {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 f.write_str(self.as_str())
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CapabilityManifest {
66 pub id: String,
67 pub version: String,
68 #[serde(rename = "type")]
69 pub capability_type: CapabilityType,
70 pub description: String,
71 #[serde(default)]
72 pub files: Vec<String>,
73 #[serde(default)]
74 pub parameters: Option<serde_json::Value>,
75 #[serde(default)]
76 pub implementation: Option<ImplementationConfig>,
77 #[serde(default)]
78 pub hook: Option<HookConfig>,
79 #[serde(default)]
80 pub workflow: Option<WorkflowConfig>,
81 #[serde(default)]
82 pub server: Option<McpServerConfig>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub policy: Option<crate::policy::PolicyConfig>,
86 #[serde(default)]
87 #[allow(dead_code)]
88 pub targets: Vec<String>,
89
90 #[serde(skip)]
91 pub root: PathBuf,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct McpServerConfig {
105 #[serde(default)]
106 pub transport: McpTransport,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub command: Option<String>,
109 #[serde(default)]
110 pub args: Vec<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub url: Option<String>,
113 #[serde(default)]
114 pub env: std::collections::BTreeMap<String, EnvRef>,
115 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
119 pub headers: std::collections::BTreeMap<String, HeaderRef>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub metadata: Option<McpServerMetadata>,
122}
123
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "lowercase")]
126pub enum McpTransport {
127 #[default]
128 Stdio,
129 Http,
130}
131
132impl McpTransport {
133 pub fn as_str(&self) -> &'static str {
134 match self {
135 Self::Stdio => "stdio",
136 Self::Http => "http",
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct EnvRef {
147 pub from_env: String,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct HeaderRef {
160 pub from_env: String,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub format: Option<String>,
163}
164
165impl HeaderRef {
166 pub fn render(&self, value_reference: &str) -> String {
170 match &self.format {
171 Some(format) => format.replacen(FORMAT_PLACEHOLDER, value_reference, 1),
172 None => value_reference.to_string(),
173 }
174 }
175}
176
177pub const FORMAT_PLACEHOLDER: &str = "{}";
179
180#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181pub struct McpServerMetadata {
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub tools_summary: Option<String>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ImplementationConfig {
188 pub language: String,
189 pub entrypoint: String,
190 #[serde(default)]
191 pub mcp: bool,
192 #[serde(default)]
193 pub runtime_deps: Vec<String>,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct HookConfig {
198 pub event: String,
199 pub command: String,
200 #[serde(default = "default_cwd")]
201 pub working_directory: String,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct WorkflowConfig {
206 pub requires: Vec<Requirement>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct Requirement {
211 pub id: String,
212 #[serde(rename = "type")]
213 pub capability_type: CapabilityType,
214}
215
216fn default_cwd() -> String {
217 ".".to_string()
218}
219
220impl CapabilityManifest {
221 pub fn source_files(&self) -> Result<Vec<PathBuf>> {
233 let mut paths = Vec::new();
234
235 for f in &self.files {
236 let path = contained_source_file(&self.root, f)?;
237 paths.push(path);
238 }
239
240 if self.capability_type == CapabilityType::Tool
241 && let Some(ref imp) = self.implementation
242 {
243 let ep_path = self.root.join(imp.entrypoint.trim_start_matches("./"));
244 if !paths.contains(&ep_path) && ep_path.exists() {
245 paths.push(contained_source_file(&self.root, &imp.entrypoint)?);
246 }
247 }
248
249 Ok(paths)
250 }
251
252 pub fn read_source_contents_with_names(&self) -> Result<Vec<(String, Vec<u8>)>> {
260 let mut installed: Vec<(String, PathBuf, Vec<u8>)> = Vec::new();
261 for p in self.source_files()? {
262 let rel = p
263 .strip_prefix(&self.root)
264 .unwrap_or(&p)
265 .to_string_lossy()
266 .replace('\\', "/");
267 let rel = rel.strip_prefix("src/").unwrap_or(&rel).to_string();
268 if let Some((_, first, _)) = installed.iter().find(|(name, _, _)| *name == rel) {
269 if *first == p {
270 continue;
271 }
272 return Err(TuffError::refused(format!(
273 "capability '{}' lists two files that would install as '{rel}': {} and {}",
274 self.id,
275 first.display(),
276 p.display()
277 ))
278 .with_hint("rename one of them, or list only one"));
279 }
280 let content = std::fs::read(&p)?;
281 installed.push((rel, p, content));
282 }
283 Ok(installed
284 .into_iter()
285 .map(|(rel, _, content)| (rel, content))
286 .collect())
287 }
288}
289
290fn contained_source_file(root: &Path, entry: &str) -> Result<PathBuf> {
297 let trimmed = entry.trim_start_matches("./");
298 let relative = crate::pack::validate_relative_path(Path::new(trimmed)).map_err(|_| {
299 TuffError::refused(format!(
300 "capability source path must stay inside the capability directory: '{entry}'"
301 ))
302 .with_hint("list files by their path relative to tuff.toml, without '..' or a leading '/'")
303 })?;
304 let mut current = root.to_path_buf();
305 for component in relative.components() {
306 current.push(component);
307 match std::fs::symlink_metadata(¤t) {
308 Ok(metadata) if metadata.file_type().is_symlink() => {
309 return Err(TuffError::refused(format!(
310 "symbolic links are not allowed in capability sources: {}",
311 current.display()
312 )));
313 }
314 Ok(_) => {}
315 Err(_) => {
316 return Err(TuffError::not_found(format!(
317 "capability source file not found: {}",
318 root.join(&relative).display()
319 )));
320 }
321 }
322 }
323 if !current.is_file() {
324 return Err(TuffError::usage(format!(
325 "capability source must be a file, not a directory: {}",
326 current.display()
327 )));
328 }
329 Ok(current)
330}
331
332pub fn validate_capability_id(id: &str) -> Result<()> {
347 let plain = !id.is_empty()
348 && id.trim() == id
349 && !id.contains(['\\', '\0'])
350 && id
351 .split('/')
352 .all(|segment| !segment.is_empty() && segment != "." && segment != "..");
353 if plain {
354 return Ok(());
355 }
356 Err(TuffError::refused(format!(
357 "capability id must be a relative path of plain names: '{}'",
358 id.escape_debug()
359 ))
360 .with_hint(
361 "use a name such as 'release-checklist' or 'security/review', without '..', '.', a leading '/', or '\\'",
362 ))
363}
364
365fn validate_non_empty(field: &str, value: &str) -> Result<()> {
366 if value.is_empty() {
367 return Err(TuffError::usage(format!(
368 "capability manifest field '{field}' must be a non-empty string"
369 )));
370 }
371 Ok(())
372}
373
374pub fn load_manifest(capability_dir: &Path) -> Result<CapabilityManifest> {
375 let manifest_path = capability_dir.join("tuff.toml");
376 if !manifest_path.exists() {
377 return Err(TuffError::not_found(format!(
378 "capability manifest not found: {}",
379 manifest_path.display()
380 )));
381 }
382
383 let raw = std::fs::read_to_string(&manifest_path)?;
384 let mut manifest = parse_manifest(&raw, &manifest_path)?;
385 manifest.root = capability_dir.to_path_buf();
386
387 validate_non_empty("id", &manifest.id)?;
388 validate_capability_id(&manifest.id)?;
389 validate_non_empty("version", &manifest.version)?;
390 validate_non_empty("type", &manifest.capability_type.to_string())?;
391 validate_non_empty("description", &manifest.description)?;
392
393 match manifest.capability_type {
394 CapabilityType::Skill => {
395 if manifest.files.is_empty() {
396 return Err(TuffError::usage(
397 "skill capability 'files' must not be empty",
398 ));
399 }
400 manifest.source_files()?;
401 }
402 CapabilityType::Tool => {
403 if manifest.parameters.is_none() {
404 return Err(TuffError::usage(
405 "tool capability requires a [parameters] section with JSON Schema",
406 ));
407 }
408 if manifest.implementation.is_none() {
409 return Err(TuffError::usage(
410 "tool capability requires an [implementation] section",
411 ));
412 }
413
414 let params = manifest.parameters.as_ref().unwrap();
415 crate::tool::validate_json_schema(params)?;
416
417 let impl_cfg = manifest.implementation.as_ref().unwrap();
418 crate::tool::validate_entrypoint(&manifest.root, &impl_cfg.entrypoint)?;
419
420 if !impl_cfg.runtime_deps.is_empty() {
421 eprintln!(
422 "note: this tool requires runtime dependencies: {}",
423 impl_cfg.runtime_deps.join(", ")
424 );
425 }
426
427 if !manifest.files.is_empty() {
428 manifest.source_files()?;
429 }
430 }
431 CapabilityType::Hook => {
432 let hook_cfg = manifest
433 .hook
434 .as_ref()
435 .ok_or_else(|| TuffError::usage("hook capability requires a [hook] section"))?;
436
437 if hook_cfg.event.trim().is_empty() {
438 return Err(TuffError::usage("hook 'event' must be a non-empty string"));
439 }
440 if hook_cfg.command.trim().is_empty() {
441 return Err(TuffError::usage(
442 "hook 'command' must be a non-empty string",
443 ));
444 }
445
446 crate::tool::check_path_traversal(&hook_cfg.working_directory)?;
447
448 eprintln!(
449 "note: this hook runs '{}' on event '{}' — it will not be executed during install",
450 hook_cfg.command, hook_cfg.event
451 );
452
453 if !manifest.files.is_empty() {
454 manifest.source_files()?;
455 }
456 }
457 CapabilityType::Workflow => {
458 let wf = manifest.workflow.as_ref().ok_or_else(|| {
459 TuffError::usage("workflow capability requires a [[workflow.requires]] section")
460 })?;
461
462 if wf.requires.is_empty() {
463 return Err(TuffError::usage(
464 "workflow 'requires' must have at least one entry",
465 ));
466 }
467
468 let mut seen = std::collections::HashSet::new();
469 for req in &wf.requires {
470 if req.id.trim().is_empty() {
471 return Err(TuffError::usage(
472 "workflow requirement 'id' must not be empty",
473 ));
474 }
475 if req.id == manifest.id {
476 return Err(TuffError::usage("workflow cannot require itself"));
477 }
478 if !seen.insert(&req.id) {
479 return Err(TuffError::usage(format!(
480 "duplicate requirement '{}' in workflow",
481 req.id
482 )));
483 }
484 }
485
486 let names: Vec<_> = wf
487 .requires
488 .iter()
489 .map(|r| format!("{} ({})", r.id, r.capability_type))
490 .collect();
491 eprintln!(
492 "note: workflow '{}' requires {} capabilities: {}",
493 manifest.id,
494 names.len(),
495 names.join(", ")
496 );
497 }
498 CapabilityType::Policy => {
499 let policy = manifest.policy.as_ref().ok_or_else(|| {
500 TuffError::usage(
501 "policy capability requires a [policy] section with at least one [[policy.rules]] entry",
502 )
503 })?;
504 crate::policy::validate_policy(policy)?;
505 if !manifest.files.is_empty() {
506 return Err(TuffError::usage(
507 "a policy capability installs no files; remove `files` from its tuff.toml",
508 ));
509 }
510 }
511 CapabilityType::McpServer => {
512 let server = manifest.server.as_ref().ok_or_else(|| {
513 TuffError::usage("mcp-server capability requires a [server] section")
514 })?;
515 validate_mcp_server(server)?;
516
517 if !manifest.files.is_empty() {
518 manifest.source_files()?;
519 }
520 }
521 }
522
523 Ok(manifest)
524}
525
526pub fn validate_mcp_server(server: &McpServerConfig) -> Result<()> {
527 match server.transport {
528 McpTransport::Stdio => {
529 if server
530 .command
531 .as_deref()
532 .is_none_or(|c| c.trim().is_empty())
533 {
534 return Err(TuffError::usage(
535 "mcp-server with transport = \"stdio\" requires a non-empty 'command'",
536 ));
537 }
538 }
539 McpTransport::Http => {
540 if server.url.as_deref().is_none_or(|u| u.trim().is_empty()) {
541 return Err(TuffError::usage(
542 "mcp-server with transport = \"http\" requires a non-empty 'url'",
543 ));
544 }
545 }
546 }
547 for (name, reference) in &server.env {
548 if name.trim().is_empty() {
549 return Err(TuffError::usage("[server.env] keys must be non-empty"));
550 }
551 if reference.from_env.trim().is_empty() {
552 return Err(TuffError::usage(format!(
553 "[server.env] {name} must reference a variable: {name} = {{ from_env = \"VAR\" }}"
554 )));
555 }
556 }
557 validate_mcp_headers(server)?;
558 Ok(())
559}
560
561fn validate_mcp_headers(server: &McpServerConfig) -> Result<()> {
565 if !server.headers.is_empty() && server.transport != McpTransport::Http {
566 return Err(TuffError::usage(
567 "[server.headers] applies to transport = \"http\"; a stdio server passes \
568 secrets through [server.env]",
569 ));
570 }
571 for (name, reference) in &server.headers {
572 if name.trim().is_empty() {
573 return Err(TuffError::usage("[server.headers] keys must be non-empty"));
574 }
575 if reference.from_env.trim().is_empty() {
576 return Err(TuffError::usage(format!(
577 "[server.headers] {name} must reference a variable: \
578 {name} = {{ from_env = \"VAR\" }}"
579 )));
580 }
581 if let Some(format) = &reference.format {
582 let placeholders = format.matches(FORMAT_PLACEHOLDER).count();
583 if placeholders != 1 {
584 let problem = if placeholders == 0 {
585 "would discard the value"
586 } else {
587 "would repeat the value"
588 };
589 return Err(TuffError::usage(format!(
590 "[server.headers] {name}: format \"{format}\" {problem}"
591 ))
592 .with_hint("format must contain exactly one {} placeholder, as in \"Bearer {}\""));
593 }
594 }
595 }
596 Ok(())
597}
598
599fn parse_manifest(raw: &str, manifest_path: &Path) -> Result<CapabilityManifest> {
603 toml::from_str(raw).map_err(|error: toml::de::Error| {
604 let message = error.to_string();
605 let looks_literal =
606 message.contains("invalid type: string") || message.contains("expected a table");
607 let literal_table = looks_literal
608 .then(|| {
609 ["[server.env]", "[server.headers]"]
610 .into_iter()
611 .find(|table| raw.contains(table))
612 })
613 .flatten();
614 if let Some(table) = literal_table {
615 let example = if table == "[server.headers]" {
616 "Authorization = { from_env = \"TOKEN\", format = \"Bearer {}\" }"
617 } else {
618 "NAME = { from_env = \"NAME\" }"
619 };
620 TuffError::usage(format!(
621 "invalid manifest at {}: {} values must be references, never \
622 literals — write {} ({})",
623 manifest_path.display(),
624 table,
625 example,
626 message.trim()
627 ))
628 } else {
629 TuffError::from(error)
630 }
631 })
632}
633
634pub fn write_manifest(path: &Path, manifest: &CapabilityManifest) -> Result<()> {
640 std::fs::write(path, toml::to_string_pretty(manifest)?)?;
641 Ok(())
642}
643
644pub fn declared_version(dir: &Path) -> Option<String> {
650 if dir.join("tuff.toml").is_file() {
651 return load_manifest(dir).ok().map(|manifest| manifest.version);
652 }
653 let skill = std::fs::read_to_string(dir.join("SKILL.md")).ok()?;
654 frontmatter_version(&skill)
655}
656
657pub fn frontmatter_version(skill: &str) -> Option<String> {
662 let mut lines = skill.lines().map(|line| line.trim_end_matches('\r'));
663 if lines.next()?.trim() != "---" {
664 return None;
665 }
666 let mut in_metadata = false;
667 let mut nested = None;
668 for line in lines {
669 if line.trim() == "---" {
670 break;
671 }
672 let indented = line.starts_with([' ', '\t']);
673 if !indented {
674 in_metadata = line.trim_end() == "metadata:";
675 if let Some(value) = line.strip_prefix("version:") {
676 return frontmatter_scalar(value);
677 }
678 continue;
679 }
680 if in_metadata
681 && nested.is_none()
682 && let Some(value) = line.trim_start().strip_prefix("version:")
683 {
684 nested = frontmatter_scalar(value);
685 }
686 }
687 nested
688}
689
690pub fn declared_description(dir: &Path) -> Option<String> {
697 if dir.join("tuff.toml").is_file() {
698 return load_manifest(dir).ok().map(|manifest| manifest.description);
699 }
700 let skill = std::fs::read_to_string(dir.join("SKILL.md")).ok()?;
701 frontmatter_description(&skill)
702}
703
704pub fn frontmatter_description(skill: &str) -> Option<String> {
710 let mut lines = skill.lines().map(|line| line.trim_end_matches('\r'));
711 if lines.next()?.trim() != "---" {
712 return None;
713 }
714 for line in lines {
715 if line.trim() == "---" {
716 break;
717 }
718 if line.starts_with([' ', '\t']) {
719 continue;
720 }
721 if let Some(value) = line.strip_prefix("description:") {
722 return frontmatter_text(value);
723 }
724 }
725 None
726}
727
728fn frontmatter_text(value: &str) -> Option<String> {
730 let value = value.trim();
731 let value = value
732 .strip_prefix('"')
733 .and_then(|rest| rest.strip_suffix('"'))
734 .or_else(|| {
735 value
736 .strip_prefix('\'')
737 .and_then(|rest| rest.strip_suffix('\''))
738 })
739 .unwrap_or(value)
740 .trim();
741 if value.is_empty() || value.starts_with(['>', '|']) {
743 return None;
744 }
745 Some(value.to_string())
746}
747
748fn frontmatter_scalar(value: &str) -> Option<String> {
749 let value = value.trim();
750 let value = value
751 .strip_prefix('"')
752 .and_then(|rest| rest.strip_suffix('"'))
753 .or_else(|| {
754 value
755 .strip_prefix('\'')
756 .and_then(|rest| rest.strip_suffix('\''))
757 })
758 .unwrap_or(value)
759 .trim();
760 (!value.is_empty() && !value.contains(char::is_whitespace)).then(|| value.to_string())
761}
762
763pub fn synthetic_manifest(
764 skill_dir: &Path,
765 name: &str,
766 version: &str,
767) -> Result<CapabilityManifest> {
768 validate_capability_id(name)?;
769 let skill_file = skill_dir.join("SKILL.md");
770 if !skill_file.exists() {
771 return Err(TuffError::not_found(format!(
772 "skill entrypoint not found: {}",
773 skill_file.display()
774 )));
775 }
776 let mut files = Vec::new();
777 walk_skill_dir(skill_dir, "", &mut files)?;
778 files.sort();
779
780 Ok(CapabilityManifest {
781 id: name.to_string(),
782 version: version.to_string(),
783 capability_type: CapabilityType::Skill,
784 description: "Installed from git source.".to_string(),
785 files,
786 parameters: None,
787 implementation: None,
788 hook: None,
789 workflow: None,
790 server: None,
791 policy: None,
792 targets: Vec::new(),
793 root: skill_dir.to_path_buf(),
794 })
795}
796
797fn walk_skill_dir(base: &Path, prefix: &str, files: &mut Vec<String>) -> Result<()> {
798 for entry in std::fs::read_dir(base)? {
799 let entry = entry?;
800 let path = entry.path();
801 let metadata = std::fs::symlink_metadata(&path)?;
802 if metadata.file_type().is_symlink() {
803 return Err(TuffError::refused(format!(
804 "symbolic links are not allowed in capability sources: {}",
805 path.display()
806 )));
807 }
808 let rel = if prefix.is_empty() {
809 entry.file_name().to_string_lossy().to_string()
810 } else {
811 format!("{}/{}", prefix, entry.file_name().to_string_lossy())
812 };
813 if metadata.is_dir() {
814 walk_skill_dir(&path, &rel, files)?;
815 } else if metadata.is_file() && rel != "tuff.toml" {
816 files.push(rel);
817 }
818 }
819 Ok(())
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
825 use std::fs;
826 use tempfile::TempDir;
827
828 fn write_manifest(dir: &std::path::Path, content: &str) {
829 fs::write(dir.join("tuff.toml"), content).unwrap();
830 }
831
832 #[test]
833 fn frontmatter_version_reads_top_level_then_metadata() {
834 assert_eq!(
835 frontmatter_version("---\nname: x\nversion: 1.2.0\n---\n# X\n").as_deref(),
836 Some("1.2.0")
837 );
838 assert_eq!(
839 frontmatter_version("---\nname: x\nversion: \"1.2.0\"\n---\n").as_deref(),
840 Some("1.2.0")
841 );
842 assert_eq!(
844 frontmatter_version(
845 "---\nname: x\nmetadata:\n author: org\n version: \"1.0\"\n---\n"
846 )
847 .as_deref(),
848 Some("1.0")
849 );
850 assert_eq!(
852 frontmatter_version("---\nmetadata:\n version: 0.9.0\nversion: 1.2.0\n---\n")
853 .as_deref(),
854 Some("1.2.0")
855 );
856 assert_eq!(
858 frontmatter_version("---\nname: x\nextra:\n version: 3.0.0\n---\n"),
859 None
860 );
861 assert_eq!(
862 frontmatter_version("# no frontmatter\nversion: 1.0.0\n"),
863 None
864 );
865 assert_eq!(
866 frontmatter_version("---\nname: x\n---\nversion: 9.9.9\n"),
867 None
868 );
869 assert_eq!(frontmatter_version("---\nversion:\n---\n"), None);
870 assert_eq!(frontmatter_version("---\nversion: 1.2.0 beta\n---\n"), None);
871 assert_eq!(
872 frontmatter_version("---\r\nname: x\r\nversion: 2.0.0\r\n---\r\n").as_deref(),
873 Some("2.0.0")
874 );
875 }
876
877 #[test]
878 fn declared_description_reads_the_frontmatter_and_prefers_the_manifest() {
879 let tmp = TempDir::new().unwrap();
880 fs::write(
881 tmp.path().join("SKILL.md"),
882 "---\nname: x\ndescription: Reviews a diff for security problems.\n---\n# X\n",
883 )
884 .unwrap();
885 assert_eq!(
886 declared_description(tmp.path()).as_deref(),
887 Some("Reviews a diff for security problems.")
888 );
889
890 write_manifest(
891 tmp.path(),
892 r#"id = "x"
893version = "1.0.0"
894type = "skill"
895description = "From the manifest"
896files = ["SKILL.md"]
897"#,
898 );
899 assert_eq!(
900 declared_description(tmp.path()).as_deref(),
901 Some("From the manifest")
902 );
903 }
904
905 #[test]
906 fn a_multi_line_description_is_left_alone_rather_than_half_read() {
907 assert_eq!(
910 frontmatter_description("---\nname: x\ndescription: >\n Long text here.\n---\n"),
911 None
912 );
913 assert_eq!(
916 frontmatter_description("---\nmetadata:\n description: Nested.\n---\n"),
917 None
918 );
919 assert_eq!(frontmatter_description("# Just a heading\n"), None);
921 }
922
923 #[test]
924 fn declared_version_prefers_the_manifest_over_the_frontmatter() {
925 let tmp = TempDir::new().unwrap();
926 fs::write(
927 tmp.path().join("SKILL.md"),
928 "---\nname: x\nversion: 2.0.0\n---\n# X\n",
929 )
930 .unwrap();
931 assert_eq!(declared_version(tmp.path()).as_deref(), Some("2.0.0"));
932 fs::write(
933 tmp.path().join("tuff.toml"),
934 "id = \"x\"\nversion = \"1.0.0\"\ntype = \"skill\"\ndescription = \"d\"\nfiles = [\"SKILL.md\"]\n",
935 )
936 .unwrap();
937 assert_eq!(declared_version(tmp.path()).as_deref(), Some("1.0.0"));
938
939 let bare = TempDir::new().unwrap();
940 fs::write(bare.path().join("SKILL.md"), "# no version\n").unwrap();
941 assert_eq!(declared_version(bare.path()), None);
942 }
943
944 #[test]
945 fn load_skill_manifest_succeeds() {
946 let tmp = TempDir::new().unwrap();
947 fs::create_dir_all(tmp.path().join("src")).unwrap();
948 fs::write(tmp.path().join("src").join("SKILL.md"), "# Skill").unwrap();
949 write_manifest(
950 tmp.path(),
951 r#"id = "test"
952version = "1.0.0"
953type = "skill"
954description = "A test skill"
955files = ["src/SKILL.md"]
956"#,
957 );
958 let m = load_manifest(tmp.path()).unwrap();
959 assert_eq!(m.id, "test");
960 assert_eq!(m.capability_type, CapabilityType::Skill);
961 }
962
963 #[test]
964 fn load_tool_manifest_succeeds() {
965 let tmp = TempDir::new().unwrap();
966 fs::write(tmp.path().join("run.sh"), "echo ok").unwrap();
967 write_manifest(
968 tmp.path(),
969 r#"id = "tool1"
970version = "1.0.0"
971type = "tool"
972description = "A test tool"
973files = ["run.sh"]
974
975[parameters]
976type = "object"
977required = ["x"]
978[parameters.properties.x]
979type = "string"
980description = "x"
981
982[implementation]
983language = "bash"
984entrypoint = "run.sh"
985"#,
986 );
987 let m = load_manifest(tmp.path()).unwrap();
988 assert_eq!(m.capability_type, CapabilityType::Tool);
989 assert!(m.implementation.is_some());
990 }
991
992 #[test]
993 fn load_hook_manifest_succeeds() {
994 let tmp = TempDir::new().unwrap();
995 write_manifest(
996 tmp.path(),
997 r#"id = "hook1"
998version = "1.0.0"
999type = "hook"
1000description = "A test hook"
1001
1002[hook]
1003event = "before_finish"
1004command = "cargo test"
1005"#,
1006 );
1007 let m = load_manifest(tmp.path()).unwrap();
1008 assert_eq!(m.capability_type, CapabilityType::Hook);
1009 assert!(m.hook.is_some());
1010 }
1011
1012 #[test]
1013 fn load_rejects_unsupported_type() {
1014 let tmp = TempDir::new().unwrap();
1015 write_manifest(
1016 tmp.path(),
1017 r#"id = "bad"
1018version = "1.0.0"
1019type = "unknown"
1020description = "Bad"
1021files = ["SKILL.md"]
1022"#,
1023 );
1024 assert!(load_manifest(tmp.path()).is_err());
1025 }
1026
1027 #[test]
1028 fn load_rejects_missing_manifest() {
1029 let tmp = TempDir::new().unwrap();
1030 assert!(load_manifest(tmp.path()).is_err());
1031 }
1032
1033 #[test]
1034 fn source_files_resolves_paths() {
1035 let tmp = TempDir::new().unwrap();
1036 fs::create_dir_all(tmp.path().join("src")).unwrap();
1037 fs::write(tmp.path().join("src").join("SKILL.md"), "skill").unwrap();
1038 let m = CapabilityManifest {
1039 id: "t".into(),
1040 version: "1.0".into(),
1041 capability_type: CapabilityType::Skill,
1042 description: "desc".into(),
1043 files: vec!["src/SKILL.md".into()],
1044 parameters: None,
1045 implementation: None,
1046 hook: None,
1047 workflow: None,
1048 server: None,
1049 policy: None,
1050 targets: vec![],
1051 root: tmp.path().to_path_buf(),
1052 };
1053 let files = m.source_files().unwrap();
1054 assert_eq!(files.len(), 1);
1055 assert!(files[0].ends_with("SKILL.md"));
1056 }
1057
1058 fn manifest_listing(root: &Path, files: &[&str]) -> CapabilityManifest {
1059 CapabilityManifest {
1060 id: "t".into(),
1061 version: "1.0".into(),
1062 capability_type: CapabilityType::Hook,
1063 description: "desc".into(),
1064 files: files.iter().map(|f| f.to_string()).collect(),
1065 parameters: None,
1066 implementation: None,
1067 hook: None,
1068 workflow: None,
1069 server: None,
1070 policy: None,
1071 targets: vec![],
1072 root: root.to_path_buf(),
1073 }
1074 }
1075
1076 #[test]
1077 fn source_files_refuse_a_path_that_climbs_out_of_the_capability() {
1078 let tmp = TempDir::new().unwrap();
1083 let capability = tmp.path().join("capability");
1084 fs::create_dir_all(&capability).unwrap();
1085 fs::write(tmp.path().join("outside.txt"), "outside").unwrap();
1086 fs::write(capability.join("inside.txt"), "inside").unwrap();
1087
1088 for entry in [
1089 "../outside.txt",
1090 "sub/../../outside.txt",
1091 "./../outside.txt",
1092 ] {
1093 let error = manifest_listing(&capability, &["inside.txt", entry])
1094 .source_files()
1095 .unwrap_err();
1096 assert_eq!(error.kind(), crate::error::ErrorKind::Refused, "{entry}");
1097 assert!(
1098 error
1099 .to_string()
1100 .contains("must stay inside the capability directory"),
1101 "{entry}: {error}"
1102 );
1103 }
1104 let absolute = tmp.path().join("outside.txt");
1105 let error = manifest_listing(&capability, &[absolute.to_str().unwrap()])
1106 .source_files()
1107 .unwrap_err();
1108 assert_eq!(error.kind(), crate::error::ErrorKind::Refused);
1109 }
1110
1111 #[cfg(unix)]
1112 #[test]
1113 fn source_files_refuse_a_symbolic_link_anywhere_along_the_path() {
1114 let tmp = TempDir::new().unwrap();
1115 let capability = tmp.path().join("capability");
1116 let secrets = tmp.path().join("secrets");
1117 fs::create_dir_all(capability.join("docs")).unwrap();
1118 fs::create_dir_all(&secrets).unwrap();
1119 fs::write(secrets.join("key.txt"), "pretend secret").unwrap();
1120 std::os::unix::fs::symlink(secrets.join("key.txt"), capability.join("notes.md")).unwrap();
1121 std::os::unix::fs::symlink(&secrets, capability.join("docs").join("linked")).unwrap();
1122
1123 for entry in ["notes.md", "docs/linked/key.txt"] {
1124 let error = manifest_listing(&capability, &[entry])
1125 .source_files()
1126 .unwrap_err();
1127 assert_eq!(error.kind(), crate::error::ErrorKind::Refused, "{entry}");
1128 assert!(
1129 error.to_string().contains("symbolic links are not allowed"),
1130 "{entry}: {error}"
1131 );
1132 }
1133 }
1134
1135 #[test]
1136 fn source_files_accept_plain_nested_and_dot_slash_paths() {
1137 let tmp = TempDir::new().unwrap();
1138 fs::create_dir_all(tmp.path().join("src/lib")).unwrap();
1139 fs::write(tmp.path().join("src/lib/check.sh"), "x").unwrap();
1140 fs::write(tmp.path().join("run.sh"), "x").unwrap();
1141
1142 let files = manifest_listing(tmp.path(), &["./run.sh", "src/lib/check.sh"])
1143 .source_files()
1144 .unwrap();
1145 assert_eq!(
1146 files,
1147 vec![
1148 tmp.path().join("run.sh"),
1149 tmp.path().join("src/lib/check.sh")
1150 ]
1151 );
1152 let error = manifest_listing(tmp.path(), &["src"])
1153 .source_files()
1154 .unwrap_err();
1155 assert!(error.to_string().contains("must be a file"), "{error}");
1156 }
1157
1158 #[test]
1159 fn listed_files_that_install_to_the_same_path_are_refused_but_a_repeat_is_not() {
1160 let tmp = TempDir::new().unwrap();
1161 fs::create_dir_all(tmp.path().join("src")).unwrap();
1162 fs::write(tmp.path().join("check.sh"), "top").unwrap();
1163 fs::write(tmp.path().join("src/check.sh"), "src").unwrap();
1164
1165 let repeated = manifest_listing(tmp.path(), &["check.sh", "./check.sh", "check.sh"])
1166 .read_source_contents_with_names()
1167 .unwrap();
1168 assert_eq!(repeated, vec![("check.sh".to_string(), b"top".to_vec())]);
1169
1170 let error = manifest_listing(tmp.path(), &["check.sh", "src/check.sh"])
1171 .read_source_contents_with_names()
1172 .unwrap_err();
1173 assert_eq!(error.kind(), crate::error::ErrorKind::Refused);
1174 assert!(
1175 error.to_string().contains("would install as 'check.sh'"),
1176 "{error}"
1177 );
1178 }
1179
1180 #[test]
1181 fn source_files_rejects_missing_file() {
1182 let tmp = TempDir::new().unwrap();
1183 let m = CapabilityManifest {
1184 id: "t".into(),
1185 version: "1.0".into(),
1186 capability_type: CapabilityType::Skill,
1187 description: "desc".into(),
1188 files: vec!["src/MISSING.md".into()],
1189 parameters: None,
1190 implementation: None,
1191 hook: None,
1192 workflow: None,
1193 server: None,
1194 policy: None,
1195 targets: vec![],
1196 root: tmp.path().to_path_buf(),
1197 };
1198 assert!(m.source_files().is_err());
1199 }
1200
1201 #[test]
1202 fn a_capability_id_is_a_relative_path_of_plain_names() {
1203 for id in [
1206 "release-checklist",
1207 "tuff-cli-guide",
1208 "a.b",
1209 "x_1",
1210 "...x",
1211 "security/security-review",
1212 "a/b/c",
1213 ] {
1214 assert!(validate_capability_id(id).is_ok(), "{id}");
1215 }
1216 for id in [
1217 "",
1218 ".",
1219 "..",
1220 "../victim",
1221 "../../victim",
1222 "a/../b",
1223 "a/..",
1224 "./a",
1225 "a/./b",
1226 "/abs",
1227 "a/",
1228 "a//b",
1229 "a\\b",
1230 "nul\0x",
1231 " padded",
1232 "padded ",
1233 ] {
1234 let error = validate_capability_id(id).unwrap_err();
1235 assert_eq!(error.kind(), crate::error::ErrorKind::Refused, "{id:?}");
1236 }
1237 }
1238
1239 #[test]
1240 fn a_manifest_with_an_escaping_id_is_refused_at_load() {
1241 let tmp = TempDir::new().unwrap();
1242 fs::write(
1243 tmp.path().join("tuff.toml"),
1244 "id = \"../../victim\"\ntype = \"hook\"\nversion = \"1.0.0\"\ndescription = \"d\"\n[hook]\nevent = \"stop\"\ncommand = \"true\"\n",
1245 )
1246 .unwrap();
1247 let error = load_manifest(tmp.path()).unwrap_err();
1248 assert!(
1249 error.to_string().contains("relative path of plain names"),
1250 "{error}"
1251 );
1252 }
1253
1254 #[test]
1255 fn a_policy_manifest_loads_and_refuses_files() {
1256 let tmp = TempDir::new().unwrap();
1257 let head = "id = \"guard\"\ntype = \"policy\"\nversion = \"1.0.0\"\ndescription = \"d\"\n";
1258 let rules =
1259 "[[policy.rules]]\neffect = \"deny\"\ncommand = [\"git\", \"push\", \"--force\"]\n";
1260 fs::write(tmp.path().join("tuff.toml"), format!("{head}{rules}")).unwrap();
1261 let manifest = load_manifest(tmp.path()).unwrap();
1262 assert_eq!(manifest.policy.unwrap().rules.len(), 1);
1263
1264 fs::write(tmp.path().join("tuff.toml"), head).unwrap();
1265 let error = load_manifest(tmp.path()).unwrap_err();
1266 assert!(
1267 error.to_string().contains("requires a [policy] section"),
1268 "{error}"
1269 );
1270
1271 fs::write(tmp.path().join("x.sh"), "x").unwrap();
1272 fs::write(
1273 tmp.path().join("tuff.toml"),
1274 format!("{head}files = [\"x.sh\"]\n{rules}"),
1275 )
1276 .unwrap();
1277 let error = load_manifest(tmp.path()).unwrap_err();
1278 assert!(error.to_string().contains("installs no files"), "{error}");
1279 }
1280
1281 #[test]
1282 fn validate_non_empty_rejects_empty() {
1283 assert!(validate_non_empty("id", "").is_err());
1284 assert!(validate_non_empty("id", "ok").is_ok());
1285 }
1286
1287 fn load_mcp(toml_body: &str) -> Result<CapabilityManifest> {
1288 let tmp = TempDir::new().unwrap();
1289 fs::write(tmp.path().join("tuff.toml"), toml_body).unwrap();
1290 load_manifest(tmp.path())
1291 }
1292
1293 const MCP_HEAD: &str =
1294 "id = \"srv\"\nversion = \"1.0.0\"\ntype = \"mcp-server\"\ndescription = \"d\"\n";
1295
1296 #[test]
1297 fn mcp_server_requires_server_section() {
1298 let error = load_mcp(MCP_HEAD).unwrap_err().to_string();
1299 assert!(error.contains("requires a [server] section"), "{error}");
1300 }
1301
1302 #[test]
1303 fn mcp_server_stdio_requires_command_and_http_requires_url() {
1304 let error = load_mcp(&format!("{MCP_HEAD}[server]\ntransport = \"stdio\"\n"))
1305 .unwrap_err()
1306 .to_string();
1307 assert!(error.contains("requires a non-empty 'command'"), "{error}");
1308 let error = load_mcp(&format!("{MCP_HEAD}[server]\ntransport = \"http\"\n"))
1309 .unwrap_err()
1310 .to_string();
1311 assert!(error.contains("requires a non-empty 'url'"), "{error}");
1312 let ok = load_mcp(&format!(
1313 "{MCP_HEAD}[server]\ntransport = \"http\"\nurl = \"https://example.test/mcp\"\n"
1314 ))
1315 .unwrap();
1316 assert_eq!(ok.server.unwrap().transport, McpTransport::Http);
1317 }
1318
1319 #[test]
1320 fn mcp_server_env_must_be_a_reference_not_a_literal() {
1321 let error = load_mcp(&format!(
1322 "{MCP_HEAD}[server]\ncommand = \"npx\"\n[server.env]\nTOKEN = \"literal\"\n"
1323 ))
1324 .unwrap_err()
1325 .to_string();
1326 assert!(error.contains("from_env"), "{error}");
1327
1328 let ok = load_mcp(&format!(
1329 "{MCP_HEAD}[server]\ncommand = \"npx\"\n[server.env]\nTOKEN = {{ from_env = \"MY_TOKEN\" }}\n"
1330 ))
1331 .unwrap();
1332 assert_eq!(ok.server.unwrap().env["TOKEN"].from_env, "MY_TOKEN");
1333 }
1334
1335 #[test]
1336 fn mcp_server_headers_must_be_references_not_literals() {
1337 let error = load_mcp(&format!(
1338 "{MCP_HEAD}[server]\ntransport = \"http\"\nurl = \"https://example.test/mcp\"\n\
1339 [server.headers]\nAuthorization = \"Bearer secret\"\n"
1340 ))
1341 .unwrap_err()
1342 .to_string();
1343 assert!(error.contains("[server.headers]"), "{error}");
1344 assert!(error.contains("from_env"), "{error}");
1345 }
1346
1347 #[test]
1348 fn mcp_server_header_reference_carries_an_optional_format() {
1349 let server = load_mcp(&format!(
1350 "{MCP_HEAD}[server]\ntransport = \"http\"\nurl = \"https://example.test/mcp\"\n\
1351 [server.headers]\n\
1352 Authorization = {{ from_env = \"NOTION_TOKEN\", format = \"Bearer {{}}\" }}\n\
1353 X-Api-Key = {{ from_env = \"API_KEY\" }}\n"
1354 ))
1355 .unwrap()
1356 .server
1357 .unwrap();
1358 assert_eq!(server.headers["Authorization"].from_env, "NOTION_TOKEN");
1359 assert_eq!(
1360 server.headers["Authorization"].render("${NOTION_TOKEN}"),
1361 "Bearer ${NOTION_TOKEN}"
1362 );
1363 assert_eq!(server.headers["X-Api-Key"].format, None);
1364 assert_eq!(
1365 server.headers["X-Api-Key"].render("${API_KEY}"),
1366 "${API_KEY}"
1367 );
1368 }
1369
1370 #[test]
1371 fn mcp_server_header_format_needs_exactly_one_placeholder() {
1372 for format in ["Bearer", "Bearer {} {}"] {
1373 let error = load_mcp(&format!(
1374 "{MCP_HEAD}[server]\ntransport = \"http\"\nurl = \"https://example.test/mcp\"\n\
1375 [server.headers]\n\
1376 Authorization = {{ from_env = \"TOKEN\", format = \"{format}\" }}\n"
1377 ))
1378 .unwrap_err()
1379 .to_string();
1380 assert!(error.contains("Authorization"), "{format}: {error}");
1381 }
1382 }
1383
1384 #[test]
1385 fn mcp_server_headers_are_refused_on_stdio() {
1386 let error = load_mcp(&format!(
1387 "{MCP_HEAD}[server]\ncommand = \"npx\"\n\
1388 [server.headers]\nAuthorization = {{ from_env = \"TOKEN\" }}\n"
1389 ))
1390 .unwrap_err()
1391 .to_string();
1392 assert!(error.contains("http"), "{error}");
1393 }
1394
1395 #[test]
1398 fn a_server_without_headers_serializes_without_the_table() {
1399 let server = load_mcp(&format!("{MCP_HEAD}[server]\ncommand = \"npx\"\n"))
1400 .unwrap()
1401 .server
1402 .unwrap();
1403 let wire = toml::to_string_pretty(&server).unwrap();
1404 assert!(!wire.contains("headers"), "{wire}");
1405 }
1406
1407 #[test]
1408 fn capability_type_round_trips_the_hyphenated_name() {
1409 assert_eq!(CapabilityType::McpServer.as_str(), "mcp-server");
1410 assert_eq!(
1411 CapabilityType::parse("mcp-server"),
1412 Some(CapabilityType::McpServer)
1413 );
1414 assert_eq!(
1415 CapabilityType::parse("mcp"),
1416 Some(CapabilityType::McpServer)
1417 );
1418 let wire = toml::to_string(&Requirement {
1419 id: "x".into(),
1420 capability_type: CapabilityType::McpServer,
1421 })
1422 .unwrap();
1423 assert!(wire.contains("type = \"mcp-server\""), "{wire}");
1424 }
1425}