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