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};
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    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub server: Option<McpServerConfig>,
94}
95
96/// What kind of string `CapabilityLockEntry::version` holds (RFC-105 D4).
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "lowercase")]
99pub enum VersionScheme {
100    /// A release chosen by tag resolution (RFC-101): `version` is the tag's
101    /// semver and `source.tag` names the tag.
102    Semver,
103    /// The version the manifest declares. Says nothing about releases.
104    #[default]
105    Declared,
106    /// A commit SHA: content-exact, semantically silent.
107    Sha,
108}
109
110/// The origin of an installed capability. Internally tagged as `kind` on
111/// the wire, so a lockfile row reads `[capabilities.source] kind = "git"`.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(tag = "kind", rename_all = "lowercase")]
114pub enum CapabilitySource {
115    Local(LocalSource),
116    Git(GitSource),
117    Catalog(CatalogSource),
118    Pack(PackProvenance),
119}
120
121impl CapabilitySource {
122    pub fn local(path: impl Into<String>) -> Self {
123        Self::Local(LocalSource { path: path.into() })
124    }
125
126    /// The `kind` string as written to the lockfile.
127    pub fn kind(&self) -> &'static str {
128        match self {
129            Self::Local(_) => "local",
130            Self::Git(_) => "git",
131            Self::Catalog(_) => "catalog",
132            Self::Pack(_) => "pack",
133        }
134    }
135
136    pub fn as_git(&self) -> Option<&GitSource> {
137        match self {
138            Self::Git(git) => Some(git),
139            _ => None,
140        }
141    }
142
143    pub fn as_pack(&self) -> Option<&PackProvenance> {
144        match self {
145            Self::Pack(pack) => Some(pack),
146            _ => None,
147        }
148    }
149
150    /// The local path a capability was installed from, when it has one.
151    pub fn local_path(&self) -> Option<&str> {
152        match self {
153            Self::Local(local) => Some(local.path.as_str()),
154            _ => None,
155        }
156    }
157
158    /// What kind of string `version` is, given where it came from (RFC-101).
159    /// A git install chosen by a release tag is `semver`; one whose version
160    /// is the pinned commit itself is `sha`; anything else, including a git
161    /// install carrying the version its manifest or frontmatter declared,
162    /// is `declared`.
163    pub fn version_scheme_for(&self, version: &str) -> VersionScheme {
164        match self {
165            Self::Git(git) if git.tag.is_some() => VersionScheme::Semver,
166            Self::Git(git) if git.git_ref == version => VersionScheme::Sha,
167            _ => VersionScheme::Declared,
168        }
169    }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct LocalSource {
174    /// Path to the source directory, relative to the lockfile's root when it
175    /// lies inside it, absolute otherwise. Empty for an adopted capability
176    /// whose only copy is the installed tree.
177    #[serde(default)]
178    pub path: String,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct GitSource {
183    pub url: String,
184    /// Subdirectory within the repository holding the capability.
185    #[serde(default)]
186    pub path: String,
187    /// The commit that was installed. Always present.
188    #[serde(rename = "ref")]
189    pub git_ref: String,
190    /// The tag that chose `ref`, when one did (RFC-101).
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub tag: Option<String>,
193    /// The range the user asked for, when they did (RFC-101).
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub requested: Option<String>,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub struct CatalogSource {
200    /// The catalog entry id: a built-in id, or the server's full registry
201    /// name when `registry` is set.
202    pub id: String,
203    /// That entry's version at install time.
204    pub version: String,
205    /// The MCP registry this entry came from, when it did not come from the
206    /// catalog compiled into the binary.
207    ///
208    /// Optional so a built-in install writes exactly what it always wrote:
209    /// an older Tuff reading a newer lockfile ignores the field rather than
210    /// failing to parse the row.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub registry: Option<String>,
213}
214
215/// Immutable pack release that delivered a capability entry.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct PackProvenance {
218    pub name: String,
219    pub version: String,
220    /// Artifact digest, bare lowercase hex. `sha256:` prefixes exist only at
221    /// the OCI boundary.
222    pub digest: String,
223    /// The OCI registry and repository this pack was pulled from
224    /// ("registry/repository", no tag), when known.
225    ///
226    /// `tuff add pack` only ever sees a local artifact file; it has no way to
227    /// know where that file came from unless the caller says so with
228    /// `--reference`. Absent, `tuff outdated` cannot check this capability
229    /// against anything and reports it as such rather than guessing.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub registry: Option<String>,
232    /// The member's path inside the pack's `sources/` tree.
233    #[serde(default)]
234    pub path: String,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct TargetLockEntry {
239    #[serde(
240        default,
241        rename = "managedHooks",
242        skip_serializing_if = "Vec::is_empty"
243    )]
244    pub managed_hooks: Vec<ManagedHook>,
245    #[serde(
246        default,
247        rename = "managedMcpEntry",
248        skip_serializing_if = "Option::is_none"
249    )]
250    pub managed_mcp_entry: Option<ManagedMcpEntry>,
251    #[serde(
252        default,
253        rename = "managedPermissions",
254        skip_serializing_if = "Vec::is_empty"
255    )]
256    pub managed_permissions: Vec<ManagedPermission>,
257    #[serde(
258        default,
259        rename = "unenforcedRules",
260        skip_serializing_if = "Vec::is_empty"
261    )]
262    pub unenforced_rules: Vec<UnenforcedRule>,
263    #[serde(default)]
264    pub ownership: TargetOwnership,
265    #[serde(default)]
266    pub sha256: String,
267    #[serde(default)]
268    pub installed_path: String,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct ManagedHook {
273    #[serde(rename = "settingsPath")]
274    pub settings_path: String,
275    pub event: String,
276    #[serde(
277        default,
278        rename = "canonicalEvent",
279        skip_serializing_if = "Option::is_none"
280    )]
281    pub canonical_event: Option<String>,
282    pub command: String,
283    #[serde(rename = "baselineHash")]
284    pub baseline_hash: String,
285}
286
287/// Baseline for one Tuff-managed `mcpServers.<id>` entry (RFC-102 stage b).
288///
289/// MCP config files are shared ground that users hand-edit, so the entry
290/// gets the managed-hook treatment: a content hash recorded at registration
291/// time, compared on every `check`/`list`, never whole-file ownership. The
292/// entry's key is the capability id, so only the file path and hash are
293/// stored.
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct ManagedMcpEntry {
296    #[serde(rename = "configPath")]
297    pub config_path: String,
298    #[serde(rename = "baselineHash")]
299    pub baseline_hash: String,
300}
301
302/// A native permission rule a policy compiled into a harness settings file,
303/// such as `Bash(git push --force *)` in `.claude/settings.json`'s
304/// `permissions.deny`. The rule string is its own identity: it is present
305/// in that list or it is not.
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307pub struct ManagedPermission {
308    #[serde(rename = "settingsPath")]
309    pub settings_path: String,
310    /// The list the rule sits in: `deny` or `ask`.
311    pub list: String,
312    pub rule: String,
313}
314
315/// A policy rule the agent does not enforce, recorded when the policy was
316/// installed with `--accept-unenforced` (RFC-107 D6). `tuff check` reports
317/// each one, and `tuff check --strict` fails while any are recorded.
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct UnenforcedRule {
320    /// One-based position of the rule in the policy.
321    pub rule: usize,
322    /// The rule as `tuff add` describes it, such as `deny read ".env"`.
323    pub description: String,
324    /// Why the agent does not enforce it.
325    pub reason: String,
326}
327
328/// Hash an MCP entry value exactly as `managed_mcp_entry_status` will when
329/// it re-reads the file: canonical `serde_json` bytes, so on-disk pretty-
330/// printing never matters.
331pub fn managed_mcp_entry_baseline(entry: &serde_json::Value) -> Result<String> {
332    Ok(hash_bytes(&serde_json::to_vec(entry)?))
333}
334
335/// `"clean"`, `"modified"`, or `"missing"` for a managed MCP entry.
336pub fn managed_mcp_entry_status(
337    repo_root: &Path,
338    capability_id: &str,
339    entry: &ManagedMcpEntry,
340) -> &'static str {
341    let path = repo_root.join(&entry.config_path);
342    if crate::mcp::is_toml(&path) {
343        return match crate::mcp::toml_entry(&path, capability_id) {
344            Ok(Some(current)) => match serde_json::to_vec(&current) {
345                Ok(bytes) if hash_bytes(&bytes) == entry.baseline_hash => "clean",
346                _ => "modified",
347            },
348            Ok(None) => "missing",
349            Err(_) => "modified",
350        };
351    }
352    let servers_key = crate::mcp::servers_key(&path);
353    let Ok(raw) = std::fs::read_to_string(path) else {
354        return "missing";
355    };
356    let Ok(config): std::result::Result<serde_json::Value, _> = serde_json::from_str(&raw) else {
357        return "modified";
358    };
359    let Some(current) = config
360        .get(servers_key)
361        .and_then(|servers| servers.get(capability_id))
362    else {
363        return "missing";
364    };
365    match serde_json::to_vec(current) {
366        Ok(bytes) if hash_bytes(&bytes) == entry.baseline_hash => "clean",
367        _ => "modified",
368    }
369}
370
371pub fn managed_hooks_from_fragment(
372    repo_root: &Path,
373    settings_path: &str,
374    fragment: &serde_json::Value,
375) -> Result<Vec<ManagedHook>> {
376    managed_hooks_from_fragment_with_canonical(repo_root, settings_path, fragment, None)
377}
378
379pub fn managed_hooks_from_fragment_with_canonical(
380    _repo_root: &Path,
381    settings_path: &str,
382    fragment: &serde_json::Value,
383    canonical_event: Option<&str>,
384) -> Result<Vec<ManagedHook>> {
385    let mut managed = Vec::new();
386    let Some(events) = fragment.get("hooks").and_then(serde_json::Value::as_object) else {
387        return Ok(managed);
388    };
389
390    for (event, groups) in events {
391        let Some(groups) = groups.as_array() else {
392            continue;
393        };
394        for group in groups {
395            let hooks = group
396                .get("hooks")
397                .and_then(serde_json::Value::as_array)
398                .map_or_else(|| vec![group], |hooks| hooks.iter().collect());
399            for hook in hooks {
400                let Some(command) = hook.get("command").and_then(serde_json::Value::as_str) else {
401                    continue;
402                };
403                let baseline = serde_json::to_vec(hook)?;
404                managed.push(ManagedHook {
405                    settings_path: settings_path.to_string(),
406                    event: event.clone(),
407                    canonical_event: canonical_event.map(str::to_owned),
408                    command: command.to_string(),
409                    baseline_hash: hash_bytes(&baseline),
410                });
411            }
412        }
413    }
414    Ok(managed)
415}
416
417pub fn managed_hook_status(repo_root: &Path, hook: &ManagedHook) -> &'static str {
418    let path = repo_root.join(&hook.settings_path);
419    let Ok(settings) = std::fs::read_to_string(path) else {
420        return "missing";
421    };
422    let Ok(settings): std::result::Result<serde_json::Value, _> = serde_json::from_str(&settings)
423    else {
424        return "modified";
425    };
426    let Some(groups) = settings
427        .get("hooks")
428        .and_then(|hooks| hooks.get(&hook.event))
429        .and_then(serde_json::Value::as_array)
430    else {
431        return "missing";
432    };
433
434    for group in groups {
435        let entries = group
436            .get("hooks")
437            .and_then(serde_json::Value::as_array)
438            .map_or_else(|| vec![group], |entries| entries.iter().collect());
439        for entry in entries {
440            if entry.get("command").and_then(serde_json::Value::as_str)
441                == Some(hook.command.as_str())
442            {
443                let Ok(content) = serde_json::to_vec(entry) else {
444                    return "modified";
445                };
446                return if hash_bytes(&content) == hook.baseline_hash {
447                    "clean"
448                } else {
449                    "modified"
450                };
451            }
452        }
453    }
454    "missing"
455}
456
457#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
458#[serde(rename_all = "lowercase")]
459pub enum TargetOwnership {
460    #[default]
461    Generated,
462    Imported,
463}
464
465/// The project-scope lockfile. Never falls through to the global one: the
466/// caller resolved a scope and this is the file for it (RFC-105 D3).
467pub fn project_lockfile(repo_root: &Path) -> PathBuf {
468    repo_root.join("tuff.lock")
469}
470
471/// The lockfile for a resolved scope: `<root>/tuff.lock` for a project,
472/// the XDG state file for the global scope (where `scope_root` is the home
473/// directory). The scope is always passed, never inferred from the path.
474pub fn scoped_lockfile(scope_root: &Path, scope: crate::resolver::Scope) -> PathBuf {
475    match scope {
476        crate::resolver::Scope::Project => project_lockfile(scope_root),
477        crate::resolver::Scope::Global => crate::paths::global_lockfile(scope_root),
478    }
479}
480
481pub fn require_scoped_lockfile(
482    scope_root: &Path,
483    scope: crate::resolver::Scope,
484) -> Result<Lockfile> {
485    read_lockfile_at(&scoped_lockfile(scope_root, scope))
486}
487
488pub fn write_scoped_lockfile(
489    scope_root: &Path,
490    scope: crate::resolver::Scope,
491    lockfile: &Lockfile,
492) -> Result<()> {
493    write_lockfile_at(&scoped_lockfile(scope_root, scope), lockfile)
494}
495
496pub fn init_lockfile(repo_root: &Path) -> Result<PathBuf> {
497    let lock_path = project_lockfile(repo_root);
498    init_lockfile_at(&lock_path)?;
499    Ok(lock_path)
500}
501
502pub fn init_lockfile_at(lock_path: &Path) -> Result<()> {
503    if !lock_path.exists() {
504        write_lockfile_at(
505            lock_path,
506            &Lockfile {
507                version: LOCKFILE_VERSION,
508                capabilities: BTreeMap::new(),
509            },
510        )?;
511    }
512    Ok(())
513}
514
515pub fn require_lockfile(repo_root: &Path) -> Result<Lockfile> {
516    read_lockfile_at(&project_lockfile(repo_root))
517}
518
519/// Read a lockfile that may legitimately not exist.
520///
521/// `Ok(None)` means "no lockfile here", which is normal for the global
522/// scope on a machine that has never used `--global`. Anything else, in
523/// particular a corrupt or too-new file, is an error: reporting it as
524/// "nothing installed" would be a confident wrong answer.
525pub fn read_optional_lockfile(path: &Path) -> Result<Option<Lockfile>> {
526    match read_lockfile_at(path) {
527        Ok(lockfile) => Ok(Some(lockfile)),
528        Err(error) if error.kind() == crate::error::ErrorKind::NotFound => Ok(None),
529        Err(error) => Err(error),
530    }
531}
532
533/// Read a lockfile of any supported schema version into the current model.
534///
535/// The version is read before anything else is deserialised, so a file from
536/// a newer tuff fails with a message about versions rather than a shape
537/// error naming some field the reader has never heard of.
538pub fn read_lockfile_at(path: &Path) -> Result<Lockfile> {
539    if !path.exists() {
540        let parent = path.parent().unwrap_or(Path::new("."));
541        return Err(TuffError::not_found(format!(
542            "{} is missing",
543            parent
544                .join(path.file_name().unwrap_or(OsStr::new("tuff.lock")))
545                .display()
546        ))
547        .with_hint("run 'tuff init' first"));
548    }
549    let raw = std::fs::read_to_string(path)?;
550    let format = WireFormat::detect(&raw);
551    let version = peek_version(&raw, format, path)?;
552    if version > LOCKFILE_VERSION {
553        return Err(TuffError::unsupported(format!(
554            "unsupported lockfile version: {version} ({} was written by a newer tuff; this tuff {} reads versions {OLDEST_READABLE_LOCKFILE_VERSION} to {LOCKFILE_VERSION}, upgrade tuff)",
555            path.display(),
556            env!("CARGO_PKG_VERSION")
557        )));
558    }
559    let expected = WireFormat::for_version(version);
560    if format != expected {
561        return Err(TuffError::corrupt(format!(
562            "{} declares lockfile version {version}, which is {expected}, but the file is {format}",
563            path.display()
564        )));
565    }
566    let rows: Vec<Row> = match version {
567        1 => read_v1_rows(&raw)?,
568        2 => read_v2_rows(&raw)?,
569        _ => read_v3_rows(&raw)?,
570    };
571    let mut capabilities: BTreeMap<String, CapabilityLockEntry> = BTreeMap::new();
572    for row in rows {
573        let Row {
574            name,
575            target,
576            target_entry,
577            entry,
578        } = row;
579        // A lockfile can be committed to someone else's repository, and
580        // every command that deletes or rewrites a capability builds its
581        // paths from this name. Refuse the file rather than act on it.
582        crate::manifest::validate_capability_id(&name).map_err(|_| {
583            TuffError::corrupt(format!(
584                "{} records a capability named '{}', which is not a relative path of plain names",
585                path.display(),
586                name.escape_debug()
587            ))
588            .with_hint("remove that entry from the lockfile by hand; Tuff will not act on it")
589        })?;
590        match capabilities.entry(name) {
591            std::collections::btree_map::Entry::Occupied(mut existing) => {
592                existing.get_mut().targets.insert(target, target_entry);
593            }
594            std::collections::btree_map::Entry::Vacant(slot) => {
595                let mut entry = entry;
596                entry.targets.insert(target, target_entry);
597                slot.insert(entry);
598            }
599        }
600    }
601    Ok(Lockfile {
602        version,
603        capabilities,
604    })
605}
606
607/// One wire row folded to its capability entry plus its target.
608struct Row {
609    name: String,
610    target: String,
611    target_entry: TargetLockEntry,
612    entry: CapabilityLockEntry,
613}
614
615fn peek_version(raw: &str, format: WireFormat, path: &Path) -> Result<u8> {
616    #[derive(Deserialize)]
617    struct VersionOnly {
618        version: Option<u8>,
619    }
620    let invalid = |message: String| {
621        TuffError::corrupt(format!(
622            "{} is not a valid lockfile: {message}",
623            path.display()
624        ))
625    };
626    let peek: VersionOnly = match format {
627        WireFormat::Toml => {
628            toml::from_str(raw).map_err(|error| invalid(error.message().to_string()))?
629        }
630        WireFormat::Json => {
631            serde_json::from_str(raw).map_err(|error| invalid(error.to_string()))?
632        }
633    };
634    match peek.version {
635        Some(version) if version >= OLDEST_READABLE_LOCKFILE_VERSION => Ok(version),
636        Some(version) => Err(TuffError::unsupported(format!(
637            "unsupported lockfile version: {version} ({} predates every schema this tuff reads)",
638            path.display()
639        ))),
640        None => Err(TuffError::corrupt(format!(
641            "{} has no version field; it is not a Tuff lockfile or it is corrupt",
642            path.display()
643        ))),
644    }
645}
646
647/// Schema version 1, read for migration only (RFC-105 D5). Never written.
648fn read_v1_rows(raw: &str) -> Result<Vec<Row>> {
649    let wire: WireLockfileV1 = toml::from_str(raw)
650        .map_err(|error| TuffError::corrupt(format!("invalid version 1 lockfile: {error}")))?;
651    Ok(wire
652        .capabilities
653        .into_iter()
654        .map(|item| {
655            let source = match item.pack {
656                // A pack member was written as "local" with an empty path
657                // plus a pack table; the pack is the real origin. The member
658                // path inside the pack was not recorded in v1, and the member
659                // id is what `tuff add pack` used, so it is the best backfill.
660                Some(pack) => CapabilitySource::Pack(PackProvenance {
661                    name: pack.name,
662                    version: pack.version,
663                    digest: pack.digest,
664                    registry: pack.registry,
665                    path: item.name.clone(),
666                }),
667                None => match item.source.as_str() {
668                    "git" => CapabilitySource::Git(GitSource {
669                        url: item.repository,
670                        path: item.source_path,
671                        git_ref: item.resolved_ref,
672                        tag: None,
673                        requested: None,
674                    }),
675                    // A v1 lockfile predates registry installs, so every
676                    // catalog row in one came from the built-in catalog.
677                    "catalog" => CapabilitySource::Catalog(CatalogSource {
678                        id: item.source_path,
679                        version: item.resolved_ref,
680                        registry: None,
681                    }),
682                    // The generated capability index wrote a sentinel path
683                    // in v1; it has no source tree and v2 says so plainly.
684                    _ if item.source_path == "<generated>" => CapabilitySource::local(""),
685                    _ => CapabilitySource::local(item.source_path),
686                },
687            };
688            let version_scheme = source.version_scheme_for(&item.version);
689            Row {
690                name: item.name,
691                target: item.target,
692                target_entry: TargetLockEntry {
693                    managed_hooks: item.managed_hooks,
694                    managed_mcp_entry: item.managed_mcp_entry,
695                    managed_permissions: Vec::new(),
696                    unenforced_rules: Vec::new(),
697                    ownership: item.ownership,
698                    sha256: item.sha256,
699                    installed_path: item.installed_path,
700                },
701                entry: CapabilityLockEntry {
702                    capability_type: item.capability_type,
703                    version: item.version,
704                    version_scheme,
705                    description: item.description,
706                    source,
707                    targets: BTreeMap::new(),
708                    implementation: item.implementation,
709                    parameters: item.parameters,
710                    server: item.server,
711                },
712            }
713        })
714        .collect())
715}
716
717/// Schema version 2: the current rows, TOML-encoded. Read for migration
718/// only; never written.
719fn read_v2_rows(raw: &str) -> Result<Vec<Row>> {
720    let wire: WireLockfile = toml::from_str(raw).map_err(|error| invalid_lockfile(&error))?;
721    Ok(rows_from_wire(wire))
722}
723
724/// Schema version 3: the current rows, JSON-encoded.
725fn read_v3_rows(raw: &str) -> Result<Vec<Row>> {
726    let wire: WireLockfile = serde_json::from_str(raw).map_err(|error| invalid_lockfile(&error))?;
727    Ok(rows_from_wire(wire))
728}
729
730/// A lockfile that does not parse, with a pointer for the one known cause:
731/// an entry of a capability type Tuff no longer has.
732fn invalid_lockfile(error: &dyn std::fmt::Display) -> TuffError {
733    let message = error.to_string();
734    let corrupt = TuffError::corrupt(format!("invalid lockfile: {message}"));
735    if message.contains("unknown variant `workflow`") {
736        corrupt.with_hint(
737            "Tuff no longer has workflow capabilities; remove the workflow entries from tuff.lock and their workflows/ folders",
738        )
739    } else {
740        corrupt
741    }
742}
743
744fn rows_from_wire(wire: WireLockfile) -> Vec<Row> {
745    wire.capabilities
746        .into_iter()
747        .map(|item| Row {
748            name: item.name,
749            target: item.target,
750            target_entry: TargetLockEntry {
751                managed_hooks: item.managed_hooks,
752                managed_mcp_entry: item.managed_mcp_entry,
753                managed_permissions: item.managed_permissions,
754                unenforced_rules: item.unenforced_rules,
755                ownership: item.ownership,
756                sha256: item.sha256,
757                installed_path: item.installed_path,
758            },
759            entry: CapabilityLockEntry {
760                capability_type: item.capability_type,
761                version: item.version,
762                version_scheme: item.version_scheme,
763                description: item.description,
764                source: item.source,
765                targets: BTreeMap::new(),
766                implementation: item.implementation,
767                parameters: item.parameters,
768                server: item.server,
769            },
770        })
771        .collect()
772}
773
774pub fn write_lockfile(repo_root: &Path, lockfile: &Lockfile) -> Result<()> {
775    write_lockfile_at(&project_lockfile(repo_root), lockfile)
776}
777
778pub fn write_lockfile_at(path: &Path, lockfile: &Lockfile) -> Result<()> {
779    if let Some(parent) = path.parent() {
780        std::fs::create_dir_all(parent)?;
781    }
782    std::fs::write(path, render_lockfile(lockfile)?)?;
783    Ok(())
784}
785
786/// The bytes `write_lockfile_at` writes: the current schema, whatever
787/// schema the lockfile was read from.
788pub fn render_lockfile(lockfile: &Lockfile) -> Result<String> {
789    let mut capabilities = Vec::new();
790    for (name, entry) in &lockfile.capabilities {
791        for (target, target_entry) in &entry.targets {
792            capabilities.push(WireCapability {
793                name: name.clone(),
794                capability_type: entry.capability_type,
795                version: entry.version.clone(),
796                version_scheme: entry.version_scheme,
797                description: entry.description.clone(),
798                target: target.clone(),
799                installed_path: target_entry.installed_path.clone(),
800                sha256: target_entry.sha256.clone(),
801                ownership: target_entry.ownership,
802                source: entry.source.clone(),
803                managed_hooks: target_entry.managed_hooks.clone(),
804                managed_mcp_entry: target_entry.managed_mcp_entry.clone(),
805                managed_permissions: target_entry.managed_permissions.clone(),
806                unenforced_rules: target_entry.unenforced_rules.clone(),
807                implementation: entry.implementation.clone(),
808                parameters: entry.parameters.clone(),
809                server: entry.server.clone(),
810            });
811        }
812    }
813    capabilities.sort_by(|a, b| {
814        a.name
815            .cmp(&b.name)
816            .then_with(|| a.capability_type.as_str().cmp(b.capability_type.as_str()))
817            .then_with(|| a.target.cmp(&b.target))
818            .then_with(|| a.installed_path.cmp(&b.installed_path))
819    });
820    let wire = WireLockfile {
821        version: LOCKFILE_VERSION,
822        capabilities,
823    };
824    // `to_string_pretty` is the `JSON.stringify(value, null, 2)` layout;
825    // the trailing newline is the one thing it leaves out. See
826    // `LOCKFILE_VERSION` for why this layout and no other.
827    let mut content = serde_json::to_string_pretty(&wire)?;
828    content.push('\n');
829    Ok(content)
830}
831
832/// The current rows (RFC-105 D1): one per capability per target. Written
833/// as JSON since schema version 3; version 2 was the same rows in TOML,
834/// which is why scalars come first and tables after, so that serializer
835/// never had to emit a value beneath a table. JSON has no such constraint,
836/// but the order is the field order readers of the file expect.
837#[derive(Debug, Serialize, Deserialize)]
838struct WireLockfile {
839    version: u8,
840    capabilities: Vec<WireCapability>,
841}
842
843#[derive(Debug, Serialize, Deserialize)]
844struct WireCapability {
845    name: String,
846    #[serde(rename = "type")]
847    capability_type: CapabilityType,
848    #[serde(default)]
849    version: String,
850    #[serde(default)]
851    version_scheme: VersionScheme,
852    #[serde(default, skip_serializing_if = "String::is_empty")]
853    description: String,
854    target: String,
855    installed_path: String,
856    sha256: String,
857    #[serde(default)]
858    ownership: TargetOwnership,
859    source: CapabilitySource,
860    #[serde(default, skip_serializing_if = "Vec::is_empty")]
861    managed_hooks: Vec<ManagedHook>,
862    #[serde(default, skip_serializing_if = "Option::is_none")]
863    managed_mcp_entry: Option<ManagedMcpEntry>,
864    #[serde(default, skip_serializing_if = "Vec::is_empty")]
865    managed_permissions: Vec<ManagedPermission>,
866    #[serde(default, skip_serializing_if = "Vec::is_empty")]
867    unenforced_rules: Vec<UnenforcedRule>,
868    #[serde(default, skip_serializing_if = "Option::is_none")]
869    implementation: Option<ImplementationConfig>,
870    #[serde(default, skip_serializing_if = "Option::is_none")]
871    parameters: Option<serde_json::Value>,
872    #[serde(default, skip_serializing_if = "Option::is_none")]
873    server: Option<McpServerConfig>,
874}
875
876/// Schema version 1 as tuff 0.1.x wrote it. Read-only; see `read_v1_rows`.
877#[derive(Debug, Deserialize)]
878struct WireLockfileV1 {
879    #[allow(dead_code)]
880    version: u8,
881    capabilities: Vec<WireCapabilityV1>,
882}
883
884#[derive(Debug, Deserialize)]
885struct WireCapabilityV1 {
886    name: String,
887    #[serde(rename = "type")]
888    capability_type: CapabilityType,
889    source: String,
890    #[serde(default)]
891    repository: String,
892    #[serde(default)]
893    source_path: String,
894    #[serde(default)]
895    resolved_ref: String,
896    sha256: String,
897    target: String,
898    installed_path: String,
899    #[serde(default)]
900    version: String,
901    #[serde(default)]
902    description: String,
903    #[serde(default)]
904    ownership: TargetOwnership,
905    #[serde(default)]
906    managed_hooks: Vec<ManagedHook>,
907    #[serde(default)]
908    managed_mcp_entry: Option<ManagedMcpEntry>,
909    #[serde(default)]
910    pack: Option<PackProvenanceV1>,
911    #[serde(default)]
912    implementation: Option<ImplementationConfig>,
913    #[serde(default)]
914    parameters: Option<serde_json::Value>,
915    #[serde(default)]
916    server: Option<McpServerConfig>,
917}
918
919#[derive(Debug, Deserialize)]
920struct PackProvenanceV1 {
921    name: String,
922    version: String,
923    digest: String,
924    #[serde(default)]
925    registry: Option<String>,
926}
927
928pub fn hash_bytes(content: &[u8]) -> String {
929    let mut hasher = Sha256::new();
930    hasher.update(content);
931    format!("{:x}", hasher.finalize())
932}
933
934pub fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
935    path.strip_prefix(repo_root)
936        .map(|relative| relative.to_string_lossy().replace('\\', "/"))
937        .unwrap_or_else(|_| path.to_string_lossy().to_string())
938}
939
940pub fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
941    if path.is_absolute() {
942        path.to_path_buf()
943    } else {
944        repo_root.join(path)
945    }
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951    use std::fs;
952    use tempfile::TempDir;
953
954    #[test]
955    fn init_lockfile_at_creates_new_file() {
956        let tmp = TempDir::new().unwrap();
957        let path = tmp.path().join("tuff.lock");
958        init_lockfile_at(&path).unwrap();
959        assert!(path.exists());
960
961        let lf = read_lockfile_at(&path).unwrap();
962        assert_eq!(lf.version, LOCKFILE_VERSION);
963        assert!(lf.capabilities.is_empty());
964    }
965
966    #[test]
967    fn read_lockfile_at_rejects_missing() {
968        let tmp = TempDir::new().unwrap();
969        let path = tmp.path().join("tuff.lock");
970        assert!(read_lockfile_at(&path).is_err());
971    }
972
973    #[test]
974    fn read_lockfile_at_rejects_a_newer_schema_in_either_encoding() {
975        let tmp = TempDir::new().unwrap();
976        let path = tmp.path().join("tuff.lock");
977        for raw in [
978            "{\n  \"version\": 4,\n  \"capabilities\": []\n}\n",
979            "version = 4\ncapabilities = []\n",
980        ] {
981            fs::write(&path, raw).unwrap();
982            let error = read_lockfile_at(&path).unwrap_err().to_string();
983            assert!(error.contains("unsupported lockfile version: 4"), "{error}");
984        }
985    }
986
987    #[test]
988    fn a_lockfile_in_the_wrong_encoding_for_its_version_is_corrupt() {
989        // Version 3 is JSON and versions 1 and 2 are TOML; a file claiming
990        // one in the syntax of the other was rewritten by something that
991        // is not tuff, and the message says which way round it is.
992        let tmp = TempDir::new().unwrap();
993        let path = tmp.path().join("tuff.lock");
994        fs::write(&path, "version = 3\ncapabilities = []\n").unwrap();
995        let error = read_lockfile_at(&path).unwrap_err().to_string();
996        assert!(
997            error.contains("declares lockfile version 3, which is JSON, but the file is TOML"),
998            "{error}"
999        );
1000        fs::write(&path, "{\"version\": 2, \"capabilities\": []}\n").unwrap();
1001        let error = read_lockfile_at(&path).unwrap_err().to_string();
1002        assert!(
1003            error.contains("declares lockfile version 2, which is TOML, but the file is JSON"),
1004            "{error}"
1005        );
1006    }
1007
1008    #[test]
1009    fn an_empty_lockfile_is_canonical_json() {
1010        let tmp = TempDir::new().unwrap();
1011        let path = tmp.path().join("tuff.lock");
1012        init_lockfile_at(&path).unwrap();
1013        assert_eq!(
1014            fs::read_to_string(&path).unwrap(),
1015            "{\n  \"version\": 3,\n  \"capabilities\": []\n}\n"
1016        );
1017    }
1018
1019    #[test]
1020    fn write_and_read_roundtrip() {
1021        let tmp = TempDir::new().unwrap();
1022        let path = tmp.path().join("tuff.lock");
1023        let mut lf = Lockfile {
1024            version: LOCKFILE_VERSION,
1025            capabilities: BTreeMap::new(),
1026        };
1027        lf.capabilities.insert(
1028            "test".into(),
1029            CapabilityLockEntry {
1030                capability_type: CapabilityType::Skill,
1031                version: "1.0".into(),
1032                version_scheme: VersionScheme::Declared,
1033                description: "test skill".into(),
1034                source: CapabilitySource::local(""),
1035                targets: BTreeMap::from([(
1036                    "open-agents".into(),
1037                    TargetLockEntry {
1038                        managed_hooks: Vec::new(),
1039                        managed_mcp_entry: None,
1040                        managed_permissions: Vec::new(),
1041                        unenforced_rules: Vec::new(),
1042                        ownership: TargetOwnership::Generated,
1043                        sha256: hash_bytes(b"content"),
1044                        installed_path: ".agents/skills/test".into(),
1045                    },
1046                )]),
1047                implementation: None,
1048                parameters: None,
1049                server: None,
1050            },
1051        );
1052        write_lockfile_at(&path, &lf).unwrap();
1053        let read = read_lockfile_at(&path).unwrap();
1054        assert_eq!(read.capabilities.len(), 1);
1055        assert_eq!(read.version, LOCKFILE_VERSION);
1056
1057        // The layout is the one `JSON.stringify(value, null, 2)` produces:
1058        // two-space indentation, nothing trailing, one newline at the end.
1059        let written = fs::read_to_string(&path).unwrap();
1060        assert!(
1061            written.starts_with(
1062                "{\n  \"version\": 3,\n  \"capabilities\": [\n    {\n      \"name\": \"test\",\n"
1063            ),
1064            "{written}"
1065        );
1066        assert!(written.ends_with("\n  ]\n}\n"), "{written}");
1067        for line in written.lines() {
1068            let indent = line.len() - line.trim_start_matches(' ').len();
1069            assert_eq!(indent % 2, 0, "odd indentation: {line:?}");
1070            assert!(!line.contains('\t'), "tab in {line:?}");
1071            assert_eq!(line, line.trim_end(), "trailing whitespace in {line:?}");
1072        }
1073        serde_json::from_str::<serde_json::Value>(&written).unwrap();
1074    }
1075
1076    #[test]
1077    fn missing_target_ownership_defaults_to_generated() {
1078        let tmp = TempDir::new().unwrap();
1079        let path = tmp.path().join("tuff.lock");
1080        fs::write(&path, "version = 1\ncapabilities = []\n").unwrap();
1081        let read = read_lockfile_at(&path).unwrap();
1082        assert!(read.capabilities.is_empty());
1083    }
1084
1085    #[test]
1086    fn hash_bytes_produces_consistent_output() {
1087        let h1 = hash_bytes(b"hello");
1088        let h2 = hash_bytes(b"hello");
1089        assert_eq!(h1, h2);
1090        assert_eq!(h1.len(), 64);
1091        assert_ne!(h1, hash_bytes(b"world"));
1092    }
1093
1094    #[test]
1095    fn a_version_1_lockfile_migrates_every_source_kind() {
1096        let tmp = TempDir::new().unwrap();
1097        let path = tmp.path().join("tuff.lock");
1098        fs::write(
1099            &path,
1100            r#"version = 1
1101
1102[[capabilities]]
1103name = "git-skill"
1104type = "skill"
1105source = "git"
1106repository = "https://example.com/skills.git"
1107source_path = "skills/git-skill"
1108resolved_ref = "9b9c499"
1109sha256 = "aa"
1110target = "open-agents"
1111installed_path = ".agents/skills/git-skill"
1112version = "9b9c499"
1113
1114[[capabilities]]
1115name = "memory"
1116type = "mcp-server"
1117source = "catalog"
1118repository = "builtin"
1119source_path = "memory"
1120resolved_ref = "1.0.0"
1121sha256 = "bb"
1122target = "open-agents"
1123installed_path = ".agents/mcp-servers/memory"
1124version = "1.0.0"
1125
1126[[capabilities]]
1127name = "pack-skill"
1128type = "skill"
1129source = "local"
1130source_path = ""
1131resolved_ref = ""
1132sha256 = "cc"
1133target = "open-agents"
1134installed_path = ".agents/skills/pack-skill"
1135version = "1.5.0"
1136
1137[capabilities.pack]
1138name = "com.acme/fixture"
1139version = "1.0.0"
1140digest = "dd"
1141registry = "ghcr.io/acme/fixture"
1142
1143[[capabilities]]
1144name = "local-skill"
1145type = "skill"
1146source = "local"
1147source_path = "sources/local-skill"
1148resolved_ref = ""
1149sha256 = "ee"
1150target = "open-agents"
1151installed_path = ".agents/skills/local-skill"
1152version = "1.0.0"
1153"#,
1154        )
1155        .unwrap();
1156
1157        let lf = read_lockfile_at(&path).unwrap();
1158        assert_eq!(lf.version, 1, "the version read is reported, not rewritten");
1159        assert_eq!(
1160            lf.capabilities["git-skill"].source,
1161            CapabilitySource::Git(GitSource {
1162                url: "https://example.com/skills.git".into(),
1163                path: "skills/git-skill".into(),
1164                git_ref: "9b9c499".into(),
1165                tag: None,
1166                requested: None,
1167            })
1168        );
1169        assert_eq!(
1170            lf.capabilities["git-skill"].version_scheme,
1171            VersionScheme::Sha
1172        );
1173        assert_eq!(
1174            lf.capabilities["memory"].source,
1175            CapabilitySource::Catalog(CatalogSource {
1176                id: "memory".into(),
1177                version: "1.0.0".into(),
1178                registry: None,
1179            })
1180        );
1181        assert_eq!(
1182            lf.capabilities["pack-skill"].source,
1183            CapabilitySource::Pack(PackProvenance {
1184                name: "com.acme/fixture".into(),
1185                version: "1.0.0".into(),
1186                digest: "dd".into(),
1187                registry: Some("ghcr.io/acme/fixture".into()),
1188                path: "pack-skill".into(),
1189            })
1190        );
1191        assert_eq!(
1192            lf.capabilities["local-skill"].source,
1193            CapabilitySource::local("sources/local-skill")
1194        );
1195        assert_eq!(
1196            lf.capabilities["local-skill"].version_scheme,
1197            VersionScheme::Declared
1198        );
1199
1200        // Writing produces v3, and v3 round-trips byte for byte.
1201        write_lockfile_at(&path, &lf).unwrap();
1202        let written = fs::read_to_string(&path).unwrap();
1203        assert!(written.starts_with("{\n  \"version\": 3,\n"), "{written}");
1204        assert!(written.contains("\"kind\": \"pack\""), "{written}");
1205        assert!(!written.contains("resolved_ref"));
1206        let again = read_lockfile_at(&path).unwrap();
1207        assert_eq!(again.version, 3);
1208        write_lockfile_at(&path, &again).unwrap();
1209        assert_eq!(fs::read_to_string(&path).unwrap(), written);
1210    }
1211
1212    #[test]
1213    fn a_version_2_lockfile_is_read_as_is_and_written_as_version_3() {
1214        let tmp = TempDir::new().unwrap();
1215        let path = tmp.path().join("tuff.lock");
1216        fs::write(
1217            &path,
1218            r#"version = 2
1219
1220[[capabilities]]
1221name = "git-skill"
1222type = "skill"
1223version = "1.4.0"
1224version_scheme = "semver"
1225target = "open-agents"
1226installed_path = ".agents/skills/git-skill"
1227sha256 = "aa"
1228ownership = "generated"
1229
1230[capabilities.source]
1231kind = "git"
1232url = "https://example.com/skills.git"
1233path = "skills/git-skill"
1234ref = "9b9c499"
1235tag = "v1.4.0"
1236requested = "^1.2"
1237"#,
1238        )
1239        .unwrap();
1240        let lf = read_lockfile_at(&path).unwrap();
1241        assert_eq!(lf.version, 2, "the version read is reported, not rewritten");
1242        let entry = &lf.capabilities["git-skill"];
1243        assert_eq!(entry.version_scheme, VersionScheme::Semver);
1244        assert_eq!(
1245            entry.source,
1246            CapabilitySource::Git(GitSource {
1247                url: "https://example.com/skills.git".into(),
1248                path: "skills/git-skill".into(),
1249                git_ref: "9b9c499".into(),
1250                tag: Some("v1.4.0".into()),
1251                requested: Some("^1.2".into()),
1252            })
1253        );
1254
1255        write_lockfile_at(&path, &lf).unwrap();
1256        let written = fs::read_to_string(&path).unwrap();
1257        assert!(written.starts_with("{\n  \"version\": 3,\n"), "{written}");
1258        assert!(written.contains("\"requested\": \"^1.2\""), "{written}");
1259        let again = read_lockfile_at(&path).unwrap();
1260        assert_eq!(again.version, 3);
1261        assert_eq!(again.capabilities["git-skill"].source, entry.source);
1262    }
1263
1264    #[test]
1265    fn a_lockfile_without_a_version_is_corrupt_not_empty() {
1266        let tmp = TempDir::new().unwrap();
1267        let path = tmp.path().join("tuff.lock");
1268        fs::write(&path, "capabilities = []\n").unwrap();
1269        let error = read_lockfile_at(&path).unwrap_err().to_string();
1270        assert!(error.contains("no version field"), "{error}");
1271
1272        fs::write(&path, "version = 2\n[[capabilities]\n").unwrap();
1273        let error = read_lockfile_at(&path).unwrap_err().to_string();
1274        assert!(error.contains("not a valid lockfile"), "{error}");
1275    }
1276
1277    #[test]
1278    fn managed_mcp_entry_status_tracks_the_entry_not_the_file() {
1279        let tmp = TempDir::new().unwrap();
1280        let config_path = tmp.path().join("mcp.json");
1281        let entry_value = serde_json::json!({"command": "npx", "args": ["-y", "srv"]});
1282        let both = |neighbour: &str| {
1283            serde_json::to_string_pretty(&serde_json::json!({
1284                "mcpServers": {"github": entry_value, "neighbour": {"command": neighbour}}
1285            }))
1286            .unwrap()
1287        };
1288        fs::write(&config_path, both("hand")).unwrap();
1289        let managed = ManagedMcpEntry {
1290            config_path: "mcp.json".into(),
1291            baseline_hash: managed_mcp_entry_baseline(&entry_value).unwrap(),
1292        };
1293
1294        // Pretty-printing and neighbouring hand-written entries never matter,
1295        // and editing the neighbour leaves ours clean.
1296        assert_eq!(
1297            managed_mcp_entry_status(tmp.path(), "github", &managed),
1298            "clean"
1299        );
1300        fs::write(&config_path, both("edited")).unwrap();
1301        assert_eq!(
1302            managed_mcp_entry_status(tmp.path(), "github", &managed),
1303            "clean"
1304        );
1305
1306        // Editing our entry is modified; removing it, or the file, is missing.
1307        fs::write(
1308            &config_path,
1309            r#"{"mcpServers": {"github": {"command": "tampered"}}}"#,
1310        )
1311        .unwrap();
1312        assert_eq!(
1313            managed_mcp_entry_status(tmp.path(), "github", &managed),
1314            "modified"
1315        );
1316        fs::write(&config_path, r#"{"mcpServers": {}}"#).unwrap();
1317        assert_eq!(
1318            managed_mcp_entry_status(tmp.path(), "github", &managed),
1319            "missing"
1320        );
1321        fs::remove_file(&config_path).unwrap();
1322        assert_eq!(
1323            managed_mcp_entry_status(tmp.path(), "github", &managed),
1324            "missing"
1325        );
1326    }
1327}