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(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/// Baseline for one Tuff-managed `mcpServers.<id>` entry (RFC-102 stage b).
287///
288/// MCP config files are shared ground that users hand-edit, so the entry
289/// gets the managed-hook treatment: a content hash recorded at registration
290/// time, compared on every `check`/`list`, never whole-file ownership. The
291/// entry's key is the capability id, so only the file path and hash are
292/// stored.
293#[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/// A native permission rule a policy compiled into a harness settings file,
302/// such as `Bash(git push --force *)` in `.claude/settings.json`'s
303/// `permissions.deny`. The rule string is its own identity: it is present
304/// in that list or it is not.
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub struct ManagedPermission {
307    #[serde(rename = "settingsPath")]
308    pub settings_path: String,
309    /// The list the rule sits in: `deny` or `ask`.
310    pub list: String,
311    pub rule: String,
312}
313
314/// Hash an MCP entry value exactly as `managed_mcp_entry_status` will when
315/// it re-reads the file: canonical `serde_json` bytes, so on-disk pretty-
316/// printing never matters.
317pub fn managed_mcp_entry_baseline(entry: &serde_json::Value) -> Result<String> {
318    Ok(hash_bytes(&serde_json::to_vec(entry)?))
319}
320
321/// `"clean"`, `"modified"`, or `"missing"` for a managed MCP entry.
322pub 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
440/// The project-scope lockfile. Never falls through to the global one: the
441/// caller resolved a scope and this is the file for it (RFC-105 D3).
442pub fn project_lockfile(repo_root: &Path) -> PathBuf {
443    repo_root.join("tuff.lock")
444}
445
446/// The lockfile for a resolved scope: `<root>/tuff.lock` for a project,
447/// the XDG state file for the global scope (where `scope_root` is the home
448/// directory). The scope is always passed, never inferred from the path.
449pub 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
494/// Read a lockfile that may legitimately not exist.
495///
496/// `Ok(None)` means "no lockfile here", which is normal for the global
497/// scope on a machine that has never used `--global`. Anything else, in
498/// particular a corrupt or too-new file, is an error: reporting it as
499/// "nothing installed" would be a confident wrong answer.
500pub 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
508/// Read a lockfile of any supported schema version into the current model.
509///
510/// The version is read before anything else is deserialised, so a file from
511/// a newer tuff fails with a message about versions rather than a shape
512/// error naming some field the reader has never heard of.
513pub 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        // A lockfile can be committed to someone else's repository, and
555        // every command that deletes or rewrites a capability builds its
556        // paths from this name. Refuse the file rather than act on it.
557        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
582/// One wire row folded to its capability entry plus its target.
583struct 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
622/// Schema version 1, read for migration only (RFC-105 D5). Never written.
623fn 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                // A pack member was written as "local" with an empty path
632                // plus a pack table; the pack is the real origin. The member
633                // path inside the pack was not recorded in v1, and the member
634                // id is what `tuff add pack` used, so it is the best backfill.
635                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                    // A v1 lockfile predates registry installs, so every
651                    // catalog row in one came from the built-in catalog.
652                    "catalog" => CapabilitySource::Catalog(CatalogSource {
653                        id: item.source_path,
654                        version: item.resolved_ref,
655                        registry: None,
656                    }),
657                    // The generated capability index wrote a sentinel path
658                    // in v1; it has no source tree and v2 says so plainly.
659                    _ 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
692/// Schema version 2: the current rows, TOML-encoded. Read for migration
693/// only; never written.
694fn 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
700/// Schema version 3: the current rows, JSON-encoded.
701fn 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    // `to_string_pretty` is the `JSON.stringify(value, null, 2)` layout;
781    // the trailing newline is the one thing it leaves out. See
782    // `LOCKFILE_VERSION` for why this layout and no other.
783    let mut content = serde_json::to_string_pretty(&wire)?;
784    content.push('\n');
785    std::fs::write(path, content)?;
786    Ok(())
787}
788
789/// The current rows (RFC-105 D1): one per capability per target. Written
790/// as JSON since schema version 3; version 2 was the same rows in TOML,
791/// which is why scalars come first and tables after, so that serializer
792/// never had to emit a value beneath a table. JSON has no such constraint,
793/// but the order is the field order readers of the file expect.
794#[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/// Schema version 1 as tuff 0.1.x wrote it. Read-only; see `read_v1_rows`.
834#[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        // Version 3 is JSON and versions 1 and 2 are TOML; a file claiming
949        // one in the syntax of the other was rewritten by something that
950        // is not tuff, and the message says which way round it is.
951        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        // The layout is the one `JSON.stringify(value, null, 2)` produces:
1017        // two-space indentation, nothing trailing, one newline at the end.
1018        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        // Writing produces v3, and v3 round-trips byte for byte.
1160        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        // Pretty-printing and neighbouring hand-written entries never matter,
1254        // and editing the neighbour leaves ours clean.
1255        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        // Editing our entry is modified; removing it, or the file, is missing.
1266        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}