Skip to main content

tuff_core/
lockfile.rs

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
13/// Current on-disk schema. Older readable versions are migrated in memory
14/// by `read_lockfile_at`; writers always emit this version.
15///
16/// Versions 1 and 2 are TOML. Version 3 carries the same rows as version 2
17/// encoded as JSON, in the layout `JSON.stringify(value, null, 2)` produces:
18/// two-space indent, one array element per line, a trailing newline. That
19/// is the layout npm, jq, Python, and VS Code's JSON formatter all agree
20/// on, so a formatter that a repository runs over its JSON files leaves
21/// the lockfile byte for byte unchanged instead of rewriting it.
22pub const LOCKFILE_VERSION: u8 = 3;
23/// Oldest schema this build still reads.
24pub const OLDEST_READABLE_LOCKFILE_VERSION: u8 = 1;
25
26/// How a lockfile is encoded on disk. Decided by the file's first byte,
27/// never by its version: a lockfile that has been mangled into the wrong
28/// syntax must be reported as such, not parsed as whatever it claims.
29#[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    /// The encoding a schema version is defined in.
45    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    /// The schema version the file was read as, or `LOCKFILE_VERSION` for a
62    /// lockfile built in memory. Writers ignore it and emit the current one.
63    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    /// The capability's own version: a declared manifest version, or the
72    /// commit that was installed when nothing better exists. Which one is
73    /// recorded in `version_scheme`, never guessed from the string.
74    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    /// Where this capability came from. One typed value, so every lifecycle
80    /// verb dispatches on `match` instead of comparing strings.
81    pub source: CapabilitySource,
82    pub targets: BTreeMap<String, TargetLockEntry>,
83    /// Cached from the manifest at install/update time, the same way
84    /// `description` is: after install, only the `files` a manifest declares
85    /// get copied to disk, `tuff.toml` itself does not, so this is the only
86    /// durable record of how a tool is invoked. Consumed by the generated
87    /// capability-index skill (RFC-103 tier 1).
88    #[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    /// Same rationale as `implementation`/`parameters`: a workflow's
93    /// `requires` list lives only in its manifest, which isn't copied to the
94    /// installed target directory.
95    #[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/// What kind of string `CapabilityLockEntry::version` holds (RFC-105 D4).
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum VersionScheme {
105    /// A release chosen by tag resolution (RFC-101): `version` is the tag's
106    /// semver and `source.tag` names the tag.
107    Semver,
108    /// The version the manifest declares. Says nothing about releases.
109    #[default]
110    Declared,
111    /// A commit SHA: content-exact, semantically silent.
112    Sha,
113}
114
115/// The origin of an installed capability. Internally tagged as `kind` on
116/// the wire, so a lockfile row reads `[capabilities.source] kind = "git"`.
117#[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    /// The `kind` string as written to the lockfile.
132    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    /// The local path a capability was installed from, when it has one.
156    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    /// What kind of string `version` is, given where it came from (RFC-101).
164    /// A git install chosen by a release tag is `semver`; one whose version
165    /// is the pinned commit itself is `sha`; anything else, including a git
166    /// install carrying the version its manifest or frontmatter declared,
167    /// is `declared`.
168    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    /// Path to the source directory, relative to the lockfile's root when it
180    /// lies inside it, absolute otherwise. Empty for an adopted capability
181    /// whose only copy is the installed tree.
182    #[serde(default)]
183    pub path: String,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct GitSource {
188    pub url: String,
189    /// Subdirectory within the repository holding the capability.
190    #[serde(default)]
191    pub path: String,
192    /// The commit that was installed. Always present.
193    #[serde(rename = "ref")]
194    pub git_ref: String,
195    /// The tag that chose `ref`, when one did (RFC-101).
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub tag: Option<String>,
198    /// The range the user asked for, when they did (RFC-101).
199    #[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    /// The catalog entry id: a built-in id, or the server's full registry
206    /// name when `registry` is set.
207    pub id: String,
208    /// That entry's version at install time.
209    pub version: String,
210    /// The MCP registry this entry came from, when it did not come from the
211    /// catalog compiled into the binary.
212    ///
213    /// Optional so a built-in install writes exactly what it always wrote:
214    /// an older Tuff reading a newer lockfile ignores the field rather than
215    /// failing to parse the row.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub registry: Option<String>,
218}
219
220/// Immutable pack release that delivered a capability entry.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct PackProvenance {
223    pub name: String,
224    pub version: String,
225    /// Artifact digest, bare lowercase hex. `sha256:` prefixes exist only at
226    /// the OCI boundary.
227    pub digest: String,
228    /// The OCI registry and repository this pack was pulled from
229    /// ("registry/repository", no tag), when known.
230    ///
231    /// `tuff add pack` only ever sees a local artifact file; it has no way to
232    /// know where that file came from unless the caller says so with
233    /// `--reference`. Absent, `tuff outdated` cannot check this capability
234    /// against anything and reports it as such rather than guessing.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub registry: Option<String>,
237    /// The member's path inside the pack's `sources/` tree.
238    #[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/// Baseline for one Tuff-managed `mcpServers.<id>` entry (RFC-102 stage b).
293///
294/// MCP config files are shared ground that users hand-edit, so the entry
295/// gets the managed-hook treatment: a content hash recorded at registration
296/// time, compared on every `check`/`list`, never whole-file ownership. The
297/// entry's key is the capability id, so only the file path and hash are
298/// stored.
299#[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/// A native permission rule a policy compiled into a harness settings file,
308/// such as `Bash(git push --force *)` in `.claude/settings.json`'s
309/// `permissions.deny`. The rule string is its own identity: it is present
310/// in that list or it is not.
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct ManagedPermission {
313    #[serde(rename = "settingsPath")]
314    pub settings_path: String,
315    /// The list the rule sits in: `deny` or `ask`.
316    pub list: String,
317    pub rule: String,
318}
319
320/// A policy rule the agent does not enforce, recorded when the policy was
321/// installed with `--accept-unenforced` (RFC-107 D6). `tuff check` reports
322/// each one, and `tuff check --strict` fails while any are recorded.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct UnenforcedRule {
325    /// One-based position of the rule in the policy.
326    pub rule: usize,
327    /// The rule as `tuff add` describes it, such as `deny read ".env"`.
328    pub description: String,
329    /// Why the agent does not enforce it.
330    pub reason: String,
331}
332
333/// Hash an MCP entry value exactly as `managed_mcp_entry_status` will when
334/// it re-reads the file: canonical `serde_json` bytes, so on-disk pretty-
335/// printing never matters.
336pub fn managed_mcp_entry_baseline(entry: &serde_json::Value) -> Result<String> {
337    Ok(hash_bytes(&serde_json::to_vec(entry)?))
338}
339
340/// `"clean"`, `"modified"`, or `"missing"` for a managed MCP entry.
341pub 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
459/// The project-scope lockfile. Never falls through to the global one: the
460/// caller resolved a scope and this is the file for it (RFC-105 D3).
461pub fn project_lockfile(repo_root: &Path) -> PathBuf {
462    repo_root.join("tuff.lock")
463}
464
465/// The lockfile for a resolved scope: `<root>/tuff.lock` for a project,
466/// the XDG state file for the global scope (where `scope_root` is the home
467/// directory). The scope is always passed, never inferred from the path.
468pub 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
513/// Read a lockfile that may legitimately not exist.
514///
515/// `Ok(None)` means "no lockfile here", which is normal for the global
516/// scope on a machine that has never used `--global`. Anything else, in
517/// particular a corrupt or too-new file, is an error: reporting it as
518/// "nothing installed" would be a confident wrong answer.
519pub 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
527/// Read a lockfile of any supported schema version into the current model.
528///
529/// The version is read before anything else is deserialised, so a file from
530/// a newer tuff fails with a message about versions rather than a shape
531/// error naming some field the reader has never heard of.
532pub 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        // A lockfile can be committed to someone else's repository, and
574        // every command that deletes or rewrites a capability builds its
575        // paths from this name. Refuse the file rather than act on it.
576        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
601/// One wire row folded to its capability entry plus its target.
602struct 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
641/// Schema version 1, read for migration only (RFC-105 D5). Never written.
642fn 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                // A pack member was written as "local" with an empty path
651                // plus a pack table; the pack is the real origin. The member
652                // path inside the pack was not recorded in v1, and the member
653                // id is what `tuff add pack` used, so it is the best backfill.
654                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                    // A v1 lockfile predates registry installs, so every
670                    // catalog row in one came from the built-in catalog.
671                    "catalog" => CapabilitySource::Catalog(CatalogSource {
672                        id: item.source_path,
673                        version: item.resolved_ref,
674                        registry: None,
675                    }),
676                    // The generated capability index wrote a sentinel path
677                    // in v1; it has no source tree and v2 says so plainly.
678                    _ 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
712/// Schema version 2: the current rows, TOML-encoded. Read for migration
713/// only; never written.
714fn 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
720/// Schema version 3: the current rows, JSON-encoded.
721fn 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    // `to_string_pretty` is the `JSON.stringify(value, null, 2)` layout;
803    // the trailing newline is the one thing it leaves out. See
804    // `LOCKFILE_VERSION` for why this layout and no other.
805    let mut content = serde_json::to_string_pretty(&wire)?;
806    content.push('\n');
807    std::fs::write(path, content)?;
808    Ok(())
809}
810
811/// The current rows (RFC-105 D1): one per capability per target. Written
812/// as JSON since schema version 3; version 2 was the same rows in TOML,
813/// which is why scalars come first and tables after, so that serializer
814/// never had to emit a value beneath a table. JSON has no such constraint,
815/// but the order is the field order readers of the file expect.
816#[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/// Schema version 1 as tuff 0.1.x wrote it. Read-only; see `read_v1_rows`.
858#[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        // Version 3 is JSON and versions 1 and 2 are TOML; a file claiming
973        // one in the syntax of the other was rewritten by something that
974        // is not tuff, and the message says which way round it is.
975        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        // The layout is the one `JSON.stringify(value, null, 2)` produces:
1042        // two-space indentation, nothing trailing, one newline at the end.
1043        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        // Writing produces v3, and v3 round-trips byte for byte.
1185        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        // Pretty-printing and neighbouring hand-written entries never matter,
1279        // and editing the neighbour leaves ours clean.
1280        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        // Editing our entry is modified; removing it, or the file, is missing.
1291        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}