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