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