Skip to main content

solana_ratchet_lock/
lib.rs

1//! `ratchet.lock` — a committed snapshot of a program's public surface.
2//!
3//! Teams commit a lockfile to their repository so CI can diff a newly
4//! built IDL against a known-good baseline without hitting an RPC. A
5//! lockfile is just a serialized [`ProgramSurface`] wrapped in a small
6//! envelope (format version, canonical JSON encoding) so it diffs well
7//! in pull requests.
8
9use std::fs;
10use std::path::Path;
11
12use anyhow::{Context, Result};
13use ratchet_core::ProgramSurface;
14use serde::{Deserialize, Serialize};
15
16/// Current lockfile schema version. Bump when the shape is
17/// incompatibly changed.
18pub const CURRENT_VERSION: u32 = 1;
19
20/// Conventional filename written and read by the CLI.
21pub const DEFAULT_FILENAME: &str = "ratchet.lock";
22
23/// On-disk representation of a ratchet lockfile.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Lockfile {
26    pub version: u32,
27    /// Elevated from `surface.program_id` so tooling can read the lock's
28    /// bound program without deserialising the full surface. Present
29    /// whenever the surface had a program id at lock time.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub program_id: Option<String>,
32    /// Program name at lock time. Useful to spot lockfile/target
33    /// mismatches early ("this is vault.lock but you passed
34    /// target/idl/treasury.json"). Optional for backward compatibility
35    /// with v0 lockfiles that predate the envelope fields.
36    #[serde(default)]
37    pub program_name: String,
38    pub surface: ProgramSurface,
39}
40
41impl Lockfile {
42    /// Wrap a surface into a current-version lockfile.
43    pub fn of(surface: ProgramSurface) -> Self {
44        Self {
45            version: CURRENT_VERSION,
46            program_id: surface.program_id.clone(),
47            program_name: surface.name.clone(),
48            surface,
49        }
50    }
51
52    /// Return `Err` when the candidate surface binds a program id that
53    /// disagrees with what the lockfile captured, or whose program name
54    /// differs. When either side is missing an identifier, the check is
55    /// skipped — loud failure requires both sides to have named the same
56    /// thing. Returns `Ok(())` on match and on missing-either-side.
57    pub fn ensure_matches(&self, candidate: &ProgramSurface) -> anyhow::Result<()> {
58        if !self.program_name.is_empty()
59            && !candidate.name.is_empty()
60            && self.program_name != candidate.name
61        {
62            anyhow::bail!(
63                "lockfile is for program `{}`, but the candidate IDL's name is `{}`",
64                self.program_name,
65                candidate.name
66            );
67        }
68        if let (Some(locked_pid), Some(candidate_pid)) =
69            (self.program_id.as_deref(), candidate.program_id.as_deref())
70        {
71            if locked_pid != candidate_pid {
72                anyhow::bail!(
73                    "lockfile was captured against program id `{locked_pid}`, but the \
74                     candidate IDL binds `{candidate_pid}`. If this is intentional, regenerate \
75                     the lockfile with `ratchet lock`.",
76                );
77            }
78        }
79        Ok(())
80    }
81
82    /// Serialize to pretty JSON (stable field order via `BTreeMap`
83    /// inside `ProgramSurface`, so diffs stay tight).
84    pub fn to_json(&self) -> Result<String> {
85        let mut json = serde_json::to_string_pretty(self).context("serializing lockfile")?;
86        json.push('\n');
87        Ok(json)
88    }
89
90    pub fn from_json(s: &str) -> Result<Self> {
91        let lock: Self = serde_json::from_str(s).context("parsing lockfile JSON")?;
92        if lock.version != CURRENT_VERSION {
93            anyhow::bail!(
94                "unsupported ratchet.lock version {}: this binary expects v{CURRENT_VERSION}",
95                lock.version
96            );
97        }
98        Ok(lock)
99    }
100
101    pub fn write(&self, path: impl AsRef<Path>) -> Result<()> {
102        let path = path.as_ref();
103        let json = self.to_json()?;
104        fs::write(path, json).with_context(|| format!("writing {}", path.display()))
105    }
106
107    pub fn read(path: impl AsRef<Path>) -> Result<Self> {
108        let path = path.as_ref();
109        let content =
110            fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
111        Self::from_json(&content)
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use ratchet_core::{AccountDef, FieldDef, PrimitiveType, ProgramSurface, TypeRef};
119
120    fn sample_surface() -> ProgramSurface {
121        let mut s = ProgramSurface {
122            name: "vault".into(),
123            program_id: Some("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS".into()),
124            version: Some("0.1.0".into()),
125            ..Default::default()
126        };
127        s.accounts.insert(
128            "Vault".into(),
129            AccountDef {
130                name: "Vault".into(),
131                discriminator: [1, 2, 3, 4, 5, 6, 7, 8],
132                fields: vec![FieldDef {
133                    name: "balance".into(),
134                    ty: TypeRef::primitive(PrimitiveType::U64),
135                    offset: None,
136                    size: None,
137                }],
138                size: None,
139            },
140        );
141        s
142    }
143
144    #[test]
145    fn round_trip() {
146        let lock = Lockfile::of(sample_surface());
147        let json = lock.to_json().unwrap();
148        let back = Lockfile::from_json(&json).unwrap();
149        assert_eq!(back.version, CURRENT_VERSION);
150        assert_eq!(back.surface.name, "vault");
151        assert_eq!(
152            back.surface.accounts["Vault"].discriminator,
153            [1, 2, 3, 4, 5, 6, 7, 8]
154        );
155    }
156
157    #[test]
158    fn rejects_future_version() {
159        let future =
160            r#"{ "version": 9999, "program_name": "vault", "surface": { "name": "vault" } }"#;
161        let err = Lockfile::from_json(future).unwrap_err();
162        assert!(format!("{err}").contains("unsupported"));
163    }
164
165    #[test]
166    fn write_then_read_from_disk() {
167        let lock = Lockfile::of(sample_surface());
168        let mut path = std::env::temp_dir();
169        path.push(format!(
170            "ratchet-lock-test-{}-{}.lock",
171            std::process::id(),
172            std::time::SystemTime::now()
173                .duration_since(std::time::UNIX_EPOCH)
174                .unwrap()
175                .as_nanos()
176        ));
177        lock.write(&path).unwrap();
178        let back = Lockfile::read(&path).unwrap();
179        assert_eq!(back.surface.name, "vault");
180        let _ = std::fs::remove_file(&path);
181    }
182
183    #[test]
184    fn pretty_json_is_stable_and_newline_terminated() {
185        let lock = Lockfile::of(sample_surface());
186        let json = lock.to_json().unwrap();
187        assert!(json.ends_with('\n'));
188        assert!(json.contains("\"version\": 1"));
189        assert!(json.contains("\"Vault\""));
190    }
191
192    #[test]
193    fn envelope_surfaces_program_id_and_name() {
194        let lock = Lockfile::of(sample_surface());
195        assert_eq!(lock.program_name, "vault");
196        assert_eq!(
197            lock.program_id.as_deref(),
198            Some("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS")
199        );
200    }
201
202    #[test]
203    fn ensure_matches_accepts_identical_identity() {
204        let lock = Lockfile::of(sample_surface());
205        let candidate = sample_surface();
206        lock.ensure_matches(&candidate).unwrap();
207    }
208
209    #[test]
210    fn ensure_matches_rejects_different_program_id() {
211        let lock = Lockfile::of(sample_surface());
212        let mut candidate = sample_surface();
213        candidate.program_id = Some("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL".into());
214        let err = lock.ensure_matches(&candidate).unwrap_err();
215        assert!(format!("{err}").contains("program id"));
216    }
217
218    #[test]
219    fn ensure_matches_rejects_different_program_name() {
220        let lock = Lockfile::of(sample_surface());
221        let mut candidate = sample_surface();
222        candidate.name = "treasury".into();
223        let err = lock.ensure_matches(&candidate).unwrap_err();
224        assert!(format!("{err}").contains("treasury"));
225    }
226
227    #[test]
228    fn ensure_matches_tolerates_missing_candidate_program_id() {
229        let lock = Lockfile::of(sample_surface());
230        let mut candidate = sample_surface();
231        candidate.program_id = None;
232        lock.ensure_matches(&candidate).unwrap();
233    }
234}