1use std::{
2 collections::BTreeMap,
3 ffi::OsStr,
4 path::{Path, PathBuf},
5};
6
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::error::{Result, TuffError};
11use crate::manifest::{CapabilityType, ImplementationConfig, McpServerConfig, WorkflowConfig};
12
13pub const LOCKFILE_VERSION: u8 = 3;
23pub const OLDEST_READABLE_LOCKFILE_VERSION: u8 = 1;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum WireFormat {
31 Toml,
32 Json,
33}
34
35impl WireFormat {
36 fn detect(raw: &str) -> Self {
37 if raw.trim_start().starts_with('{') {
38 Self::Json
39 } else {
40 Self::Toml
41 }
42 }
43
44 fn for_version(version: u8) -> Self {
46 if version >= 3 { Self::Json } else { Self::Toml }
47 }
48}
49
50impl std::fmt::Display for WireFormat {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.write_str(match self {
53 Self::Toml => "TOML",
54 Self::Json => "JSON",
55 })
56 }
57}
58
59#[derive(Debug, Serialize, Deserialize)]
60pub struct Lockfile {
61 pub version: u8,
64 pub capabilities: BTreeMap<String, CapabilityLockEntry>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct CapabilityLockEntry {
69 #[serde(rename = "type")]
70 pub capability_type: CapabilityType,
71 pub version: String,
75 #[serde(default)]
76 pub version_scheme: VersionScheme,
77 #[serde(default, skip_serializing_if = "String::is_empty")]
78 pub description: String,
79 pub source: CapabilitySource,
82 pub targets: BTreeMap<String, TargetLockEntry>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub implementation: Option<ImplementationConfig>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub parameters: Option<serde_json::Value>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub workflow: Option<WorkflowConfig>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub server: Option<McpServerConfig>,
99}
100
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum VersionScheme {
105 Semver,
108 #[default]
110 Declared,
111 Sha,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(tag = "kind", rename_all = "lowercase")]
119pub enum CapabilitySource {
120 Local(LocalSource),
121 Git(GitSource),
122 Catalog(CatalogSource),
123 Pack(PackProvenance),
124}
125
126impl CapabilitySource {
127 pub fn local(path: impl Into<String>) -> Self {
128 Self::Local(LocalSource { path: path.into() })
129 }
130
131 pub fn kind(&self) -> &'static str {
133 match self {
134 Self::Local(_) => "local",
135 Self::Git(_) => "git",
136 Self::Catalog(_) => "catalog",
137 Self::Pack(_) => "pack",
138 }
139 }
140
141 pub fn as_git(&self) -> Option<&GitSource> {
142 match self {
143 Self::Git(git) => Some(git),
144 _ => None,
145 }
146 }
147
148 pub fn as_pack(&self) -> Option<&PackProvenance> {
149 match self {
150 Self::Pack(pack) => Some(pack),
151 _ => None,
152 }
153 }
154
155 pub fn local_path(&self) -> Option<&str> {
157 match self {
158 Self::Local(local) => Some(local.path.as_str()),
159 _ => None,
160 }
161 }
162
163 pub fn version_scheme_for(&self, version: &str) -> VersionScheme {
169 match self {
170 Self::Git(git) if git.tag.is_some() => VersionScheme::Semver,
171 Self::Git(git) if git.git_ref == version => VersionScheme::Sha,
172 _ => VersionScheme::Declared,
173 }
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct LocalSource {
179 #[serde(default)]
183 pub path: String,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct GitSource {
188 pub url: String,
189 #[serde(default)]
191 pub path: String,
192 #[serde(rename = "ref")]
194 pub git_ref: String,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub tag: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub requested: Option<String>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct CatalogSource {
205 pub id: String,
208 pub version: String,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub registry: Option<String>,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct PackProvenance {
223 pub name: String,
224 pub version: String,
225 pub digest: String,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub registry: Option<String>,
237 #[serde(default)]
239 pub path: String,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct TargetLockEntry {
244 #[serde(
245 default,
246 rename = "managedHooks",
247 skip_serializing_if = "Vec::is_empty"
248 )]
249 pub managed_hooks: Vec<ManagedHook>,
250 #[serde(
251 default,
252 rename = "managedMcpEntry",
253 skip_serializing_if = "Option::is_none"
254 )]
255 pub managed_mcp_entry: Option<ManagedMcpEntry>,
256 #[serde(
257 default,
258 rename = "managedPermissions",
259 skip_serializing_if = "Vec::is_empty"
260 )]
261 pub managed_permissions: Vec<ManagedPermission>,
262 #[serde(
263 default,
264 rename = "unenforcedRules",
265 skip_serializing_if = "Vec::is_empty"
266 )]
267 pub unenforced_rules: Vec<UnenforcedRule>,
268 #[serde(default)]
269 pub ownership: TargetOwnership,
270 #[serde(default)]
271 pub sha256: String,
272 #[serde(default)]
273 pub installed_path: String,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct ManagedHook {
278 #[serde(rename = "settingsPath")]
279 pub settings_path: String,
280 pub event: String,
281 #[serde(
282 default,
283 rename = "canonicalEvent",
284 skip_serializing_if = "Option::is_none"
285 )]
286 pub canonical_event: Option<String>,
287 pub command: String,
288 #[serde(rename = "baselineHash")]
289 pub baseline_hash: String,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ManagedMcpEntry {
301 #[serde(rename = "configPath")]
302 pub config_path: String,
303 #[serde(rename = "baselineHash")]
304 pub baseline_hash: String,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct ManagedPermission {
313 #[serde(rename = "settingsPath")]
314 pub settings_path: String,
315 pub list: String,
317 pub rule: String,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct UnenforcedRule {
325 pub rule: usize,
327 pub description: String,
329 pub reason: String,
331}
332
333pub fn managed_mcp_entry_baseline(entry: &serde_json::Value) -> Result<String> {
337 Ok(hash_bytes(&serde_json::to_vec(entry)?))
338}
339
340pub fn managed_mcp_entry_status(
342 repo_root: &Path,
343 capability_id: &str,
344 entry: &ManagedMcpEntry,
345) -> &'static str {
346 let path = repo_root.join(&entry.config_path);
347 let Ok(raw) = std::fs::read_to_string(path) else {
348 return "missing";
349 };
350 let Ok(config): std::result::Result<serde_json::Value, _> = serde_json::from_str(&raw) else {
351 return "modified";
352 };
353 let Some(current) = config
354 .get("mcpServers")
355 .and_then(|servers| servers.get(capability_id))
356 else {
357 return "missing";
358 };
359 match serde_json::to_vec(current) {
360 Ok(bytes) if hash_bytes(&bytes) == entry.baseline_hash => "clean",
361 _ => "modified",
362 }
363}
364
365pub fn managed_hooks_from_fragment(
366 repo_root: &Path,
367 settings_path: &str,
368 fragment: &serde_json::Value,
369) -> Result<Vec<ManagedHook>> {
370 managed_hooks_from_fragment_with_canonical(repo_root, settings_path, fragment, None)
371}
372
373pub fn managed_hooks_from_fragment_with_canonical(
374 _repo_root: &Path,
375 settings_path: &str,
376 fragment: &serde_json::Value,
377 canonical_event: Option<&str>,
378) -> Result<Vec<ManagedHook>> {
379 let mut managed = Vec::new();
380 let Some(events) = fragment.get("hooks").and_then(serde_json::Value::as_object) else {
381 return Ok(managed);
382 };
383
384 for (event, groups) in events {
385 let Some(groups) = groups.as_array() else {
386 continue;
387 };
388 for group in groups {
389 let hooks = group
390 .get("hooks")
391 .and_then(serde_json::Value::as_array)
392 .map_or_else(|| vec![group], |hooks| hooks.iter().collect());
393 for hook in hooks {
394 let Some(command) = hook.get("command").and_then(serde_json::Value::as_str) else {
395 continue;
396 };
397 let baseline = serde_json::to_vec(hook)?;
398 managed.push(ManagedHook {
399 settings_path: settings_path.to_string(),
400 event: event.clone(),
401 canonical_event: canonical_event.map(str::to_owned),
402 command: command.to_string(),
403 baseline_hash: hash_bytes(&baseline),
404 });
405 }
406 }
407 }
408 Ok(managed)
409}
410
411pub fn managed_hook_status(repo_root: &Path, hook: &ManagedHook) -> &'static str {
412 let path = repo_root.join(&hook.settings_path);
413 let Ok(settings) = std::fs::read_to_string(path) else {
414 return "missing";
415 };
416 let Ok(settings): std::result::Result<serde_json::Value, _> = serde_json::from_str(&settings)
417 else {
418 return "modified";
419 };
420 let Some(groups) = settings
421 .get("hooks")
422 .and_then(|hooks| hooks.get(&hook.event))
423 .and_then(serde_json::Value::as_array)
424 else {
425 return "missing";
426 };
427
428 for group in groups {
429 let entries = group
430 .get("hooks")
431 .and_then(serde_json::Value::as_array)
432 .map_or_else(|| vec![group], |entries| entries.iter().collect());
433 for entry in entries {
434 if entry.get("command").and_then(serde_json::Value::as_str)
435 == Some(hook.command.as_str())
436 {
437 let Ok(content) = serde_json::to_vec(entry) else {
438 return "modified";
439 };
440 return if hash_bytes(&content) == hook.baseline_hash {
441 "clean"
442 } else {
443 "modified"
444 };
445 }
446 }
447 }
448 "missing"
449}
450
451#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(rename_all = "lowercase")]
453pub enum TargetOwnership {
454 #[default]
455 Generated,
456 Imported,
457}
458
459pub fn project_lockfile(repo_root: &Path) -> PathBuf {
462 repo_root.join("tuff.lock")
463}
464
465pub fn scoped_lockfile(scope_root: &Path, scope: crate::resolver::Scope) -> PathBuf {
469 match scope {
470 crate::resolver::Scope::Project => project_lockfile(scope_root),
471 crate::resolver::Scope::Global => crate::paths::global_lockfile(scope_root),
472 }
473}
474
475pub fn require_scoped_lockfile(
476 scope_root: &Path,
477 scope: crate::resolver::Scope,
478) -> Result<Lockfile> {
479 read_lockfile_at(&scoped_lockfile(scope_root, scope))
480}
481
482pub fn write_scoped_lockfile(
483 scope_root: &Path,
484 scope: crate::resolver::Scope,
485 lockfile: &Lockfile,
486) -> Result<()> {
487 write_lockfile_at(&scoped_lockfile(scope_root, scope), lockfile)
488}
489
490pub fn init_lockfile(repo_root: &Path) -> Result<PathBuf> {
491 let lock_path = project_lockfile(repo_root);
492 init_lockfile_at(&lock_path)?;
493 Ok(lock_path)
494}
495
496pub fn init_lockfile_at(lock_path: &Path) -> Result<()> {
497 if !lock_path.exists() {
498 write_lockfile_at(
499 lock_path,
500 &Lockfile {
501 version: LOCKFILE_VERSION,
502 capabilities: BTreeMap::new(),
503 },
504 )?;
505 }
506 Ok(())
507}
508
509pub fn require_lockfile(repo_root: &Path) -> Result<Lockfile> {
510 read_lockfile_at(&project_lockfile(repo_root))
511}
512
513pub fn read_optional_lockfile(path: &Path) -> Result<Option<Lockfile>> {
520 match read_lockfile_at(path) {
521 Ok(lockfile) => Ok(Some(lockfile)),
522 Err(error) if error.kind() == crate::error::ErrorKind::NotFound => Ok(None),
523 Err(error) => Err(error),
524 }
525}
526
527pub fn read_lockfile_at(path: &Path) -> Result<Lockfile> {
533 if !path.exists() {
534 let parent = path.parent().unwrap_or(Path::new("."));
535 return Err(TuffError::not_found(format!(
536 "{} is missing",
537 parent
538 .join(path.file_name().unwrap_or(OsStr::new("tuff.lock")))
539 .display()
540 ))
541 .with_hint("run 'tuff init' first"));
542 }
543 let raw = std::fs::read_to_string(path)?;
544 let format = WireFormat::detect(&raw);
545 let version = peek_version(&raw, format, path)?;
546 if version > LOCKFILE_VERSION {
547 return Err(TuffError::unsupported(format!(
548 "unsupported lockfile version: {version} ({} was written by a newer tuff; this tuff {} reads versions {OLDEST_READABLE_LOCKFILE_VERSION} to {LOCKFILE_VERSION}, upgrade tuff)",
549 path.display(),
550 env!("CARGO_PKG_VERSION")
551 )));
552 }
553 let expected = WireFormat::for_version(version);
554 if format != expected {
555 return Err(TuffError::corrupt(format!(
556 "{} declares lockfile version {version}, which is {expected}, but the file is {format}",
557 path.display()
558 )));
559 }
560 let rows: Vec<Row> = match version {
561 1 => read_v1_rows(&raw)?,
562 2 => read_v2_rows(&raw)?,
563 _ => read_v3_rows(&raw)?,
564 };
565 let mut capabilities: BTreeMap<String, CapabilityLockEntry> = BTreeMap::new();
566 for row in rows {
567 let Row {
568 name,
569 target,
570 target_entry,
571 entry,
572 } = row;
573 crate::manifest::validate_capability_id(&name).map_err(|_| {
577 TuffError::corrupt(format!(
578 "{} records a capability named '{}', which is not a relative path of plain names",
579 path.display(),
580 name.escape_debug()
581 ))
582 .with_hint("remove that entry from the lockfile by hand; Tuff will not act on it")
583 })?;
584 match capabilities.entry(name) {
585 std::collections::btree_map::Entry::Occupied(mut existing) => {
586 existing.get_mut().targets.insert(target, target_entry);
587 }
588 std::collections::btree_map::Entry::Vacant(slot) => {
589 let mut entry = entry;
590 entry.targets.insert(target, target_entry);
591 slot.insert(entry);
592 }
593 }
594 }
595 Ok(Lockfile {
596 version,
597 capabilities,
598 })
599}
600
601struct Row {
603 name: String,
604 target: String,
605 target_entry: TargetLockEntry,
606 entry: CapabilityLockEntry,
607}
608
609fn peek_version(raw: &str, format: WireFormat, path: &Path) -> Result<u8> {
610 #[derive(Deserialize)]
611 struct VersionOnly {
612 version: Option<u8>,
613 }
614 let invalid = |message: String| {
615 TuffError::corrupt(format!(
616 "{} is not a valid lockfile: {message}",
617 path.display()
618 ))
619 };
620 let peek: VersionOnly = match format {
621 WireFormat::Toml => {
622 toml::from_str(raw).map_err(|error| invalid(error.message().to_string()))?
623 }
624 WireFormat::Json => {
625 serde_json::from_str(raw).map_err(|error| invalid(error.to_string()))?
626 }
627 };
628 match peek.version {
629 Some(version) if version >= OLDEST_READABLE_LOCKFILE_VERSION => Ok(version),
630 Some(version) => Err(TuffError::unsupported(format!(
631 "unsupported lockfile version: {version} ({} predates every schema this tuff reads)",
632 path.display()
633 ))),
634 None => Err(TuffError::corrupt(format!(
635 "{} has no version field; it is not a Tuff lockfile or it is corrupt",
636 path.display()
637 ))),
638 }
639}
640
641fn read_v1_rows(raw: &str) -> Result<Vec<Row>> {
643 let wire: WireLockfileV1 = toml::from_str(raw)
644 .map_err(|error| TuffError::corrupt(format!("invalid version 1 lockfile: {error}")))?;
645 Ok(wire
646 .capabilities
647 .into_iter()
648 .map(|item| {
649 let source = match item.pack {
650 Some(pack) => CapabilitySource::Pack(PackProvenance {
655 name: pack.name,
656 version: pack.version,
657 digest: pack.digest,
658 registry: pack.registry,
659 path: item.name.clone(),
660 }),
661 None => match item.source.as_str() {
662 "git" => CapabilitySource::Git(GitSource {
663 url: item.repository,
664 path: item.source_path,
665 git_ref: item.resolved_ref,
666 tag: None,
667 requested: None,
668 }),
669 "catalog" => CapabilitySource::Catalog(CatalogSource {
672 id: item.source_path,
673 version: item.resolved_ref,
674 registry: None,
675 }),
676 _ if item.source_path == "<generated>" => CapabilitySource::local(""),
679 _ => CapabilitySource::local(item.source_path),
680 },
681 };
682 let version_scheme = source.version_scheme_for(&item.version);
683 Row {
684 name: item.name,
685 target: item.target,
686 target_entry: TargetLockEntry {
687 managed_hooks: item.managed_hooks,
688 managed_mcp_entry: item.managed_mcp_entry,
689 managed_permissions: Vec::new(),
690 unenforced_rules: Vec::new(),
691 ownership: item.ownership,
692 sha256: item.sha256,
693 installed_path: item.installed_path,
694 },
695 entry: CapabilityLockEntry {
696 capability_type: item.capability_type,
697 version: item.version,
698 version_scheme,
699 description: item.description,
700 source,
701 targets: BTreeMap::new(),
702 implementation: item.implementation,
703 parameters: item.parameters,
704 workflow: item.workflow,
705 server: item.server,
706 },
707 }
708 })
709 .collect())
710}
711
712fn read_v2_rows(raw: &str) -> Result<Vec<Row>> {
715 let wire: WireLockfile = toml::from_str(raw)
716 .map_err(|error| TuffError::corrupt(format!("invalid lockfile: {error}")))?;
717 Ok(rows_from_wire(wire))
718}
719
720fn read_v3_rows(raw: &str) -> Result<Vec<Row>> {
722 let wire: WireLockfile = serde_json::from_str(raw)
723 .map_err(|error| TuffError::corrupt(format!("invalid lockfile: {error}")))?;
724 Ok(rows_from_wire(wire))
725}
726
727fn rows_from_wire(wire: WireLockfile) -> Vec<Row> {
728 wire.capabilities
729 .into_iter()
730 .map(|item| Row {
731 name: item.name,
732 target: item.target,
733 target_entry: TargetLockEntry {
734 managed_hooks: item.managed_hooks,
735 managed_mcp_entry: item.managed_mcp_entry,
736 managed_permissions: item.managed_permissions,
737 unenforced_rules: item.unenforced_rules,
738 ownership: item.ownership,
739 sha256: item.sha256,
740 installed_path: item.installed_path,
741 },
742 entry: CapabilityLockEntry {
743 capability_type: item.capability_type,
744 version: item.version,
745 version_scheme: item.version_scheme,
746 description: item.description,
747 source: item.source,
748 targets: BTreeMap::new(),
749 implementation: item.implementation,
750 parameters: item.parameters,
751 workflow: item.workflow,
752 server: item.server,
753 },
754 })
755 .collect()
756}
757
758pub fn write_lockfile(repo_root: &Path, lockfile: &Lockfile) -> Result<()> {
759 write_lockfile_at(&project_lockfile(repo_root), lockfile)
760}
761
762pub fn write_lockfile_at(path: &Path, lockfile: &Lockfile) -> Result<()> {
763 if let Some(parent) = path.parent() {
764 std::fs::create_dir_all(parent)?;
765 }
766 let mut capabilities = Vec::new();
767 for (name, entry) in &lockfile.capabilities {
768 for (target, target_entry) in &entry.targets {
769 capabilities.push(WireCapability {
770 name: name.clone(),
771 capability_type: entry.capability_type,
772 version: entry.version.clone(),
773 version_scheme: entry.version_scheme,
774 description: entry.description.clone(),
775 target: target.clone(),
776 installed_path: target_entry.installed_path.clone(),
777 sha256: target_entry.sha256.clone(),
778 ownership: target_entry.ownership,
779 source: entry.source.clone(),
780 managed_hooks: target_entry.managed_hooks.clone(),
781 managed_mcp_entry: target_entry.managed_mcp_entry.clone(),
782 managed_permissions: target_entry.managed_permissions.clone(),
783 unenforced_rules: target_entry.unenforced_rules.clone(),
784 implementation: entry.implementation.clone(),
785 parameters: entry.parameters.clone(),
786 workflow: entry.workflow.clone(),
787 server: entry.server.clone(),
788 });
789 }
790 }
791 capabilities.sort_by(|a, b| {
792 a.name
793 .cmp(&b.name)
794 .then_with(|| a.capability_type.as_str().cmp(b.capability_type.as_str()))
795 .then_with(|| a.target.cmp(&b.target))
796 .then_with(|| a.installed_path.cmp(&b.installed_path))
797 });
798 let wire = WireLockfile {
799 version: LOCKFILE_VERSION,
800 capabilities,
801 };
802 let mut content = serde_json::to_string_pretty(&wire)?;
806 content.push('\n');
807 std::fs::write(path, content)?;
808 Ok(())
809}
810
811#[derive(Debug, Serialize, Deserialize)]
817struct WireLockfile {
818 version: u8,
819 capabilities: Vec<WireCapability>,
820}
821
822#[derive(Debug, Serialize, Deserialize)]
823struct WireCapability {
824 name: String,
825 #[serde(rename = "type")]
826 capability_type: CapabilityType,
827 #[serde(default)]
828 version: String,
829 #[serde(default)]
830 version_scheme: VersionScheme,
831 #[serde(default, skip_serializing_if = "String::is_empty")]
832 description: String,
833 target: String,
834 installed_path: String,
835 sha256: String,
836 #[serde(default)]
837 ownership: TargetOwnership,
838 source: CapabilitySource,
839 #[serde(default, skip_serializing_if = "Vec::is_empty")]
840 managed_hooks: Vec<ManagedHook>,
841 #[serde(default, skip_serializing_if = "Option::is_none")]
842 managed_mcp_entry: Option<ManagedMcpEntry>,
843 #[serde(default, skip_serializing_if = "Vec::is_empty")]
844 managed_permissions: Vec<ManagedPermission>,
845 #[serde(default, skip_serializing_if = "Vec::is_empty")]
846 unenforced_rules: Vec<UnenforcedRule>,
847 #[serde(default, skip_serializing_if = "Option::is_none")]
848 implementation: Option<ImplementationConfig>,
849 #[serde(default, skip_serializing_if = "Option::is_none")]
850 parameters: Option<serde_json::Value>,
851 #[serde(default, skip_serializing_if = "Option::is_none")]
852 workflow: Option<WorkflowConfig>,
853 #[serde(default, skip_serializing_if = "Option::is_none")]
854 server: Option<McpServerConfig>,
855}
856
857#[derive(Debug, Deserialize)]
859struct WireLockfileV1 {
860 #[allow(dead_code)]
861 version: u8,
862 capabilities: Vec<WireCapabilityV1>,
863}
864
865#[derive(Debug, Deserialize)]
866struct WireCapabilityV1 {
867 name: String,
868 #[serde(rename = "type")]
869 capability_type: CapabilityType,
870 source: String,
871 #[serde(default)]
872 repository: String,
873 #[serde(default)]
874 source_path: String,
875 #[serde(default)]
876 resolved_ref: String,
877 sha256: String,
878 target: String,
879 installed_path: String,
880 #[serde(default)]
881 version: String,
882 #[serde(default)]
883 description: String,
884 #[serde(default)]
885 ownership: TargetOwnership,
886 #[serde(default)]
887 managed_hooks: Vec<ManagedHook>,
888 #[serde(default)]
889 managed_mcp_entry: Option<ManagedMcpEntry>,
890 #[serde(default)]
891 pack: Option<PackProvenanceV1>,
892 #[serde(default)]
893 implementation: Option<ImplementationConfig>,
894 #[serde(default)]
895 parameters: Option<serde_json::Value>,
896 #[serde(default)]
897 workflow: Option<WorkflowConfig>,
898 #[serde(default)]
899 server: Option<McpServerConfig>,
900}
901
902#[derive(Debug, Deserialize)]
903struct PackProvenanceV1 {
904 name: String,
905 version: String,
906 digest: String,
907 #[serde(default)]
908 registry: Option<String>,
909}
910
911pub fn hash_bytes(content: &[u8]) -> String {
912 let mut hasher = Sha256::new();
913 hasher.update(content);
914 format!("{:x}", hasher.finalize())
915}
916
917pub fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
918 path.strip_prefix(repo_root)
919 .map(|relative| relative.to_string_lossy().replace('\\', "/"))
920 .unwrap_or_else(|_| path.to_string_lossy().to_string())
921}
922
923pub fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
924 if path.is_absolute() {
925 path.to_path_buf()
926 } else {
927 repo_root.join(path)
928 }
929}
930
931#[cfg(test)]
932mod tests {
933 use super::*;
934 use std::fs;
935 use tempfile::TempDir;
936
937 #[test]
938 fn init_lockfile_at_creates_new_file() {
939 let tmp = TempDir::new().unwrap();
940 let path = tmp.path().join("tuff.lock");
941 init_lockfile_at(&path).unwrap();
942 assert!(path.exists());
943
944 let lf = read_lockfile_at(&path).unwrap();
945 assert_eq!(lf.version, LOCKFILE_VERSION);
946 assert!(lf.capabilities.is_empty());
947 }
948
949 #[test]
950 fn read_lockfile_at_rejects_missing() {
951 let tmp = TempDir::new().unwrap();
952 let path = tmp.path().join("tuff.lock");
953 assert!(read_lockfile_at(&path).is_err());
954 }
955
956 #[test]
957 fn read_lockfile_at_rejects_a_newer_schema_in_either_encoding() {
958 let tmp = TempDir::new().unwrap();
959 let path = tmp.path().join("tuff.lock");
960 for raw in [
961 "{\n \"version\": 4,\n \"capabilities\": []\n}\n",
962 "version = 4\ncapabilities = []\n",
963 ] {
964 fs::write(&path, raw).unwrap();
965 let error = read_lockfile_at(&path).unwrap_err().to_string();
966 assert!(error.contains("unsupported lockfile version: 4"), "{error}");
967 }
968 }
969
970 #[test]
971 fn a_lockfile_in_the_wrong_encoding_for_its_version_is_corrupt() {
972 let tmp = TempDir::new().unwrap();
976 let path = tmp.path().join("tuff.lock");
977 fs::write(&path, "version = 3\ncapabilities = []\n").unwrap();
978 let error = read_lockfile_at(&path).unwrap_err().to_string();
979 assert!(
980 error.contains("declares lockfile version 3, which is JSON, but the file is TOML"),
981 "{error}"
982 );
983 fs::write(&path, "{\"version\": 2, \"capabilities\": []}\n").unwrap();
984 let error = read_lockfile_at(&path).unwrap_err().to_string();
985 assert!(
986 error.contains("declares lockfile version 2, which is TOML, but the file is JSON"),
987 "{error}"
988 );
989 }
990
991 #[test]
992 fn an_empty_lockfile_is_canonical_json() {
993 let tmp = TempDir::new().unwrap();
994 let path = tmp.path().join("tuff.lock");
995 init_lockfile_at(&path).unwrap();
996 assert_eq!(
997 fs::read_to_string(&path).unwrap(),
998 "{\n \"version\": 3,\n \"capabilities\": []\n}\n"
999 );
1000 }
1001
1002 #[test]
1003 fn write_and_read_roundtrip() {
1004 let tmp = TempDir::new().unwrap();
1005 let path = tmp.path().join("tuff.lock");
1006 let mut lf = Lockfile {
1007 version: LOCKFILE_VERSION,
1008 capabilities: BTreeMap::new(),
1009 };
1010 lf.capabilities.insert(
1011 "test".into(),
1012 CapabilityLockEntry {
1013 capability_type: CapabilityType::Skill,
1014 version: "1.0".into(),
1015 version_scheme: VersionScheme::Declared,
1016 description: "test skill".into(),
1017 source: CapabilitySource::local(""),
1018 targets: BTreeMap::from([(
1019 "open-agents".into(),
1020 TargetLockEntry {
1021 managed_hooks: Vec::new(),
1022 managed_mcp_entry: None,
1023 managed_permissions: Vec::new(),
1024 unenforced_rules: Vec::new(),
1025 ownership: TargetOwnership::Generated,
1026 sha256: hash_bytes(b"content"),
1027 installed_path: ".agents/skills/test".into(),
1028 },
1029 )]),
1030 implementation: None,
1031 parameters: None,
1032 workflow: None,
1033 server: None,
1034 },
1035 );
1036 write_lockfile_at(&path, &lf).unwrap();
1037 let read = read_lockfile_at(&path).unwrap();
1038 assert_eq!(read.capabilities.len(), 1);
1039 assert_eq!(read.version, LOCKFILE_VERSION);
1040
1041 let written = fs::read_to_string(&path).unwrap();
1044 assert!(
1045 written.starts_with(
1046 "{\n \"version\": 3,\n \"capabilities\": [\n {\n \"name\": \"test\",\n"
1047 ),
1048 "{written}"
1049 );
1050 assert!(written.ends_with("\n ]\n}\n"), "{written}");
1051 for line in written.lines() {
1052 let indent = line.len() - line.trim_start_matches(' ').len();
1053 assert_eq!(indent % 2, 0, "odd indentation: {line:?}");
1054 assert!(!line.contains('\t'), "tab in {line:?}");
1055 assert_eq!(line, line.trim_end(), "trailing whitespace in {line:?}");
1056 }
1057 serde_json::from_str::<serde_json::Value>(&written).unwrap();
1058 }
1059
1060 #[test]
1061 fn missing_target_ownership_defaults_to_generated() {
1062 let tmp = TempDir::new().unwrap();
1063 let path = tmp.path().join("tuff.lock");
1064 fs::write(&path, "version = 1\ncapabilities = []\n").unwrap();
1065 let read = read_lockfile_at(&path).unwrap();
1066 assert!(read.capabilities.is_empty());
1067 }
1068
1069 #[test]
1070 fn hash_bytes_produces_consistent_output() {
1071 let h1 = hash_bytes(b"hello");
1072 let h2 = hash_bytes(b"hello");
1073 assert_eq!(h1, h2);
1074 assert_eq!(h1.len(), 64);
1075 assert_ne!(h1, hash_bytes(b"world"));
1076 }
1077
1078 #[test]
1079 fn a_version_1_lockfile_migrates_every_source_kind() {
1080 let tmp = TempDir::new().unwrap();
1081 let path = tmp.path().join("tuff.lock");
1082 fs::write(
1083 &path,
1084 r#"version = 1
1085
1086[[capabilities]]
1087name = "git-skill"
1088type = "skill"
1089source = "git"
1090repository = "https://example.com/skills.git"
1091source_path = "skills/git-skill"
1092resolved_ref = "9b9c499"
1093sha256 = "aa"
1094target = "open-agents"
1095installed_path = ".agents/skills/git-skill"
1096version = "9b9c499"
1097
1098[[capabilities]]
1099name = "memory"
1100type = "mcp-server"
1101source = "catalog"
1102repository = "builtin"
1103source_path = "memory"
1104resolved_ref = "1.0.0"
1105sha256 = "bb"
1106target = "open-agents"
1107installed_path = ".agents/mcp-servers/memory"
1108version = "1.0.0"
1109
1110[[capabilities]]
1111name = "pack-skill"
1112type = "skill"
1113source = "local"
1114source_path = ""
1115resolved_ref = ""
1116sha256 = "cc"
1117target = "open-agents"
1118installed_path = ".agents/skills/pack-skill"
1119version = "1.5.0"
1120
1121[capabilities.pack]
1122name = "com.acme/fixture"
1123version = "1.0.0"
1124digest = "dd"
1125registry = "ghcr.io/acme/fixture"
1126
1127[[capabilities]]
1128name = "local-skill"
1129type = "skill"
1130source = "local"
1131source_path = "sources/local-skill"
1132resolved_ref = ""
1133sha256 = "ee"
1134target = "open-agents"
1135installed_path = ".agents/skills/local-skill"
1136version = "1.0.0"
1137"#,
1138 )
1139 .unwrap();
1140
1141 let lf = read_lockfile_at(&path).unwrap();
1142 assert_eq!(lf.version, 1, "the version read is reported, not rewritten");
1143 assert_eq!(
1144 lf.capabilities["git-skill"].source,
1145 CapabilitySource::Git(GitSource {
1146 url: "https://example.com/skills.git".into(),
1147 path: "skills/git-skill".into(),
1148 git_ref: "9b9c499".into(),
1149 tag: None,
1150 requested: None,
1151 })
1152 );
1153 assert_eq!(
1154 lf.capabilities["git-skill"].version_scheme,
1155 VersionScheme::Sha
1156 );
1157 assert_eq!(
1158 lf.capabilities["memory"].source,
1159 CapabilitySource::Catalog(CatalogSource {
1160 id: "memory".into(),
1161 version: "1.0.0".into(),
1162 registry: None,
1163 })
1164 );
1165 assert_eq!(
1166 lf.capabilities["pack-skill"].source,
1167 CapabilitySource::Pack(PackProvenance {
1168 name: "com.acme/fixture".into(),
1169 version: "1.0.0".into(),
1170 digest: "dd".into(),
1171 registry: Some("ghcr.io/acme/fixture".into()),
1172 path: "pack-skill".into(),
1173 })
1174 );
1175 assert_eq!(
1176 lf.capabilities["local-skill"].source,
1177 CapabilitySource::local("sources/local-skill")
1178 );
1179 assert_eq!(
1180 lf.capabilities["local-skill"].version_scheme,
1181 VersionScheme::Declared
1182 );
1183
1184 write_lockfile_at(&path, &lf).unwrap();
1186 let written = fs::read_to_string(&path).unwrap();
1187 assert!(written.starts_with("{\n \"version\": 3,\n"), "{written}");
1188 assert!(written.contains("\"kind\": \"pack\""), "{written}");
1189 assert!(!written.contains("resolved_ref"));
1190 let again = read_lockfile_at(&path).unwrap();
1191 assert_eq!(again.version, 3);
1192 write_lockfile_at(&path, &again).unwrap();
1193 assert_eq!(fs::read_to_string(&path).unwrap(), written);
1194 }
1195
1196 #[test]
1197 fn a_version_2_lockfile_is_read_as_is_and_written_as_version_3() {
1198 let tmp = TempDir::new().unwrap();
1199 let path = tmp.path().join("tuff.lock");
1200 fs::write(
1201 &path,
1202 r#"version = 2
1203
1204[[capabilities]]
1205name = "git-skill"
1206type = "skill"
1207version = "1.4.0"
1208version_scheme = "semver"
1209target = "open-agents"
1210installed_path = ".agents/skills/git-skill"
1211sha256 = "aa"
1212ownership = "generated"
1213
1214[capabilities.source]
1215kind = "git"
1216url = "https://example.com/skills.git"
1217path = "skills/git-skill"
1218ref = "9b9c499"
1219tag = "v1.4.0"
1220requested = "^1.2"
1221"#,
1222 )
1223 .unwrap();
1224 let lf = read_lockfile_at(&path).unwrap();
1225 assert_eq!(lf.version, 2, "the version read is reported, not rewritten");
1226 let entry = &lf.capabilities["git-skill"];
1227 assert_eq!(entry.version_scheme, VersionScheme::Semver);
1228 assert_eq!(
1229 entry.source,
1230 CapabilitySource::Git(GitSource {
1231 url: "https://example.com/skills.git".into(),
1232 path: "skills/git-skill".into(),
1233 git_ref: "9b9c499".into(),
1234 tag: Some("v1.4.0".into()),
1235 requested: Some("^1.2".into()),
1236 })
1237 );
1238
1239 write_lockfile_at(&path, &lf).unwrap();
1240 let written = fs::read_to_string(&path).unwrap();
1241 assert!(written.starts_with("{\n \"version\": 3,\n"), "{written}");
1242 assert!(written.contains("\"requested\": \"^1.2\""), "{written}");
1243 let again = read_lockfile_at(&path).unwrap();
1244 assert_eq!(again.version, 3);
1245 assert_eq!(again.capabilities["git-skill"].source, entry.source);
1246 }
1247
1248 #[test]
1249 fn a_lockfile_without_a_version_is_corrupt_not_empty() {
1250 let tmp = TempDir::new().unwrap();
1251 let path = tmp.path().join("tuff.lock");
1252 fs::write(&path, "capabilities = []\n").unwrap();
1253 let error = read_lockfile_at(&path).unwrap_err().to_string();
1254 assert!(error.contains("no version field"), "{error}");
1255
1256 fs::write(&path, "version = 2\n[[capabilities]\n").unwrap();
1257 let error = read_lockfile_at(&path).unwrap_err().to_string();
1258 assert!(error.contains("not a valid lockfile"), "{error}");
1259 }
1260
1261 #[test]
1262 fn managed_mcp_entry_status_tracks_the_entry_not_the_file() {
1263 let tmp = TempDir::new().unwrap();
1264 let config_path = tmp.path().join("mcp.json");
1265 let entry_value = serde_json::json!({"command": "npx", "args": ["-y", "srv"]});
1266 let both = |neighbour: &str| {
1267 serde_json::to_string_pretty(&serde_json::json!({
1268 "mcpServers": {"github": entry_value, "neighbour": {"command": neighbour}}
1269 }))
1270 .unwrap()
1271 };
1272 fs::write(&config_path, both("hand")).unwrap();
1273 let managed = ManagedMcpEntry {
1274 config_path: "mcp.json".into(),
1275 baseline_hash: managed_mcp_entry_baseline(&entry_value).unwrap(),
1276 };
1277
1278 assert_eq!(
1281 managed_mcp_entry_status(tmp.path(), "github", &managed),
1282 "clean"
1283 );
1284 fs::write(&config_path, both("edited")).unwrap();
1285 assert_eq!(
1286 managed_mcp_entry_status(tmp.path(), "github", &managed),
1287 "clean"
1288 );
1289
1290 fs::write(
1292 &config_path,
1293 r#"{"mcpServers": {"github": {"command": "tampered"}}}"#,
1294 )
1295 .unwrap();
1296 assert_eq!(
1297 managed_mcp_entry_status(tmp.path(), "github", &managed),
1298 "modified"
1299 );
1300 fs::write(&config_path, r#"{"mcpServers": {}}"#).unwrap();
1301 assert_eq!(
1302 managed_mcp_entry_status(tmp.path(), "github", &managed),
1303 "missing"
1304 );
1305 fs::remove_file(&config_path).unwrap();
1306 assert_eq!(
1307 managed_mcp_entry_status(tmp.path(), "github", &managed),
1308 "missing"
1309 );
1310 }
1311}