solana_ratchet_lock/
lib.rs1use std::fs;
10use std::path::Path;
11
12use anyhow::{Context, Result};
13use ratchet_core::ProgramSurface;
14use serde::{Deserialize, Serialize};
15
16pub const CURRENT_VERSION: u32 = 1;
19
20pub const DEFAULT_FILENAME: &str = "ratchet.lock";
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Lockfile {
26 pub version: u32,
27 #[serde(skip_serializing_if = "Option::is_none")]
31 pub program_id: Option<String>,
32 #[serde(default)]
37 pub program_name: String,
38 pub surface: ProgramSurface,
39}
40
41impl Lockfile {
42 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 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 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}