Skip to main content

pray_core/
lockfile.rs

1use crate::hashing::sha256_prefixed;
2use crate::render::RenderedTarget;
3use crate::{PrayError, PrayResult};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::{Component, Path, PathBuf};
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
9pub struct Lockfile {
10    pub prayfile_lock: String,
11    pub spec: String,
12    pub generated_by: String,
13    pub manifest_hash: String,
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub environment: Option<String>,
16    pub source: Vec<LockSource>,
17    pub package: Vec<LockedPackage>,
18    pub target: Vec<LockedTarget>,
19    pub managed_span: Vec<ManagedSpanRecord>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct LockSource {
24    pub name: String,
25    pub kind: String,
26    pub url: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub revision: Option<String>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub host_key_fingerprint: Option<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct LockedPackage {
35    pub name: String,
36    pub version: String,
37    pub source: Option<String>,
38    pub path: String,
39    pub tree_hash: String,
40    pub artifact_hash: String,
41    pub artifact: String,
42    pub exports: Vec<String>,
43    pub dependencies: Vec<String>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub signer_fingerprint: Option<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct LockedTarget {
50    pub name: String,
51    pub outputs: Vec<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct ManagedSpanRecord {
56    pub id: String,
57    pub target: String,
58    pub open_line: usize,
59    pub close_line: usize,
60    pub ideal_checksum: String,
61    pub package: String,
62    pub export: String,
63    pub source_checksum: String,
64    pub silenced: bool,
65}
66
67impl Lockfile {
68    pub fn canonicalized(&self) -> Self {
69        let mut lockfile = self.clone();
70        lockfile
71            .source
72            .sort_by(|left, right| left.name.cmp(&right.name));
73        lockfile.package.sort_by(|left, right| {
74            left.name
75                .cmp(&right.name)
76                .then(left.source.cmp(&right.source))
77                .then(left.version.cmp(&right.version))
78        });
79        lockfile
80            .target
81            .sort_by(|left, right| left.name.cmp(&right.name));
82        lockfile.managed_span.sort_by(|left, right| {
83            left.target
84                .cmp(&right.target)
85                .then(left.open_line.cmp(&right.open_line))
86                .then(left.id.cmp(&right.id))
87        });
88        lockfile
89    }
90
91    pub fn serialized(&self) -> PrayResult<String> {
92        let bytes = toml::to_string_pretty(&self.canonicalized())
93            .map_err(|error| PrayError::Manifest(error.to_string()))?;
94        Ok(bytes)
95    }
96
97    pub fn file_hash(&self) -> PrayResult<String> {
98        let text = self.serialized()?;
99        Ok(sha256_prefixed(text.as_bytes()))
100    }
101
102    pub fn equivalent_to(&self, other: &Self) -> bool {
103        self == &other.canonicalized()
104    }
105}
106
107pub fn lockfiles_equivalent(canonical: &Lockfile, other: &Lockfile) -> bool {
108    canonical.equivalent_to(other)
109}
110
111pub fn write_lockfile_if_changed(path: &Path, lockfile: &Lockfile) -> PrayResult<()> {
112    let serialized = lockfile.serialized()?;
113    if path.exists() {
114        if let Ok(existing) = fs::read(path) {
115            if existing == serialized.as_bytes() {
116                return Ok(());
117            }
118        }
119    }
120    fs::write(path, serialized)?;
121    Ok(())
122}
123
124pub fn write_lockfile(path: &Path, lockfile: &Lockfile) -> PrayResult<()> {
125    let serialized = lockfile.serialized()?;
126    fs::write(path, serialized)?;
127    Ok(())
128}
129
130pub fn read_lockfile(path: &Path) -> PrayResult<Lockfile> {
131    let text = fs::read_to_string(path)?;
132    let lockfile = toml::from_str(&text).map_err(|error| PrayError::Parse {
133        kind: "lockfile",
134        message: error.to_string(),
135    })?;
136    Ok(lockfile)
137}
138
139pub fn relative_lockfile_path(project_root: &Path, path: &Path) -> String {
140    let absolute = if path.is_absolute() {
141        path.to_path_buf()
142    } else {
143        project_root.join(path)
144    };
145    let normalized_root = lexical_normalize_path(project_root);
146    let normalized_absolute = lexical_normalize_path(&absolute);
147    let relative = normalized_absolute
148        .strip_prefix(&normalized_root)
149        .map(Path::to_path_buf)
150        .unwrap_or_else(|_| {
151            if path.is_absolute() {
152                path.to_path_buf()
153            } else {
154                lexical_normalize_path(path)
155            }
156        });
157    format_relative_lockfile_path(&relative)
158}
159
160fn format_relative_lockfile_path(relative: &Path) -> String {
161    let text = relative.to_string_lossy().replace('\\', "/");
162    if text == "." || text.starts_with("./") {
163        text
164    } else {
165        format!("./{text}")
166    }
167}
168
169fn lexical_normalize_path(path: &Path) -> PathBuf {
170    let mut normalized = PathBuf::new();
171    for component in path.components() {
172        match component {
173            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
174            Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR_STR),
175            Component::CurDir => {}
176            Component::ParentDir => {
177                let _ = normalized.pop();
178            }
179            Component::Normal(segment) => normalized.push(segment),
180        }
181    }
182    normalized
183}
184
185fn normalize_lockfile_artifact(project_root: &Path, artifact: &str, package_root: &Path) -> String {
186    if let Some(path_text) = artifact.strip_prefix("path:") {
187        let path = Path::new(path_text);
188        let relative = if path.is_absolute() {
189            relative_lockfile_path(project_root, path)
190        } else {
191            relative_lockfile_path(project_root, package_root)
192        };
193        format!("path:{relative}")
194    } else {
195        artifact.to_string()
196    }
197}
198
199#[allow(clippy::too_many_arguments)]
200pub fn build_lockfile(
201    manifest_hash: String,
202    environment: Option<String>,
203    project_root: &Path,
204    manifest_sources: &[crate::manifest::ManifestSource],
205    manifest_targets: &[crate::manifest::ManifestTarget],
206    rendered: &[RenderedTarget],
207    packages: &[crate::resolve::ResolvedPackage],
208    source_revisions: &std::collections::BTreeMap<String, String>,
209    source_host_keys: &std::collections::BTreeMap<String, String>,
210) -> Lockfile {
211    Lockfile {
212        prayfile_lock: "1".to_string(),
213        spec: "0.1".to_string(),
214        generated_by: format!("pray {}", env!("CARGO_PKG_VERSION")),
215        manifest_hash,
216        environment,
217        source: manifest_sources
218            .iter()
219            .map(|source| LockSource {
220                name: source.name.clone(),
221                kind: source.kind.clone(),
222                url: source.url.clone(),
223                revision: source_revisions.get(&source.name).cloned(),
224                host_key_fingerprint: source_host_keys.get(&source.name).cloned(),
225            })
226            .collect(),
227        package: packages
228            .iter()
229            .map(|package| LockedPackage {
230                name: package.declaration.name.clone(),
231                version: package.spec.version.clone(),
232                source: package.declaration.source.clone(),
233                path: relative_lockfile_path(project_root, &package.root),
234                tree_hash: package.tree_hash.clone(),
235                artifact_hash: package.artifact_hash.clone(),
236                artifact: normalize_lockfile_artifact(
237                    project_root,
238                    &package.artifact,
239                    &package.root,
240                ),
241                exports: package.selected_exports.clone(),
242                dependencies: package
243                    .spec
244                    .dependencies
245                    .iter()
246                    .map(|dependency| dependency.name.clone())
247                    .collect(),
248                signer_fingerprint: package.signer_fingerprint.clone(),
249            })
250            .collect(),
251        target: manifest_targets
252            .iter()
253            .map(|target| LockedTarget {
254                name: target.name.clone(),
255                outputs: target.outputs.clone(),
256            })
257            .collect(),
258        managed_span: rendered
259            .iter()
260            .flat_map(|target| target.managed_spans.iter().cloned())
261            .collect(),
262    }
263    .canonicalized()
264}
265
266#[cfg(test)]
267mod tests {
268    use super::{
269        build_lockfile, lockfiles_equivalent, normalize_lockfile_artifact, relative_lockfile_path,
270        LockSource, LockedPackage, Lockfile,
271    };
272    use std::collections::BTreeMap;
273    use std::path::Path;
274
275    #[test]
276    fn build_lockfile_records_git_source_revision() {
277        let mut source_revisions = BTreeMap::new();
278        source_revisions.insert(
279            "dist".to_string(),
280            "abc123def4567890abc123def4567890abc123de".to_string(),
281        );
282        let lockfile = build_lockfile(
283            "sha256:manifest".to_string(),
284            None,
285            Path::new("."),
286            &[crate::manifest::ManifestSource {
287                name: "dist".to_string(),
288                kind: "git".to_string(),
289                url: "git+https://example.com/dist.git".to_string(),
290                subdir: None,
291                rev: None,
292                tag: None,
293            }],
294            &[],
295            &[],
296            &[],
297            &source_revisions,
298            &BTreeMap::new(),
299        );
300        assert_eq!(
301            lockfile.source,
302            vec![LockSource {
303                name: "dist".to_string(),
304                kind: "git".to_string(),
305                url: "git+https://example.com/dist.git".to_string(),
306                revision: Some("abc123def4567890abc123def4567890abc123de".to_string()),
307                host_key_fingerprint: None,
308            }]
309        );
310        let serialized = lockfile.serialized().expect("serialize lockfile");
311        assert!(serialized.contains("revision ="));
312    }
313
314    #[test]
315    fn lockfiles_equivalent_ignores_field_order() {
316        let left = Lockfile {
317            manifest_hash: "sha256:manifest".to_string(),
318            package: vec![LockedPackage {
319                name: "alpha".to_string(),
320                version: "1.0.0".to_string(),
321                source: None,
322                path: "packages/alpha".to_string(),
323                tree_hash: "sha256:tree".to_string(),
324                artifact_hash: "sha256:artifact".to_string(),
325                artifact: "alpha-1.0.0.praypkg".to_string(),
326                exports: vec!["SKILL.md".to_string()],
327                dependencies: Vec::new(),
328                signer_fingerprint: None,
329            }],
330            ..Lockfile::default()
331        };
332        let mut right = left.clone();
333        right.package.reverse();
334        assert!(lockfiles_equivalent(&left.canonicalized(), &right));
335    }
336
337    #[test]
338    fn relative_lockfile_path_strips_absolute_project_prefix() {
339        let project_root = Path::new("/tmp/project");
340        let package_root = Path::new("/tmp/project/./packages/base");
341        assert_eq!(
342            relative_lockfile_path(project_root, package_root),
343            "./packages/base"
344        );
345    }
346
347    #[test]
348    fn normalize_lockfile_artifact_relativizes_path_artifacts() {
349        let project_root = Path::new("/tmp/project");
350        let package_root = Path::new("/tmp/project/packages/base");
351        assert_eq!(
352            normalize_lockfile_artifact(
353                project_root,
354                "path:/tmp/project/./packages/base",
355                package_root,
356            ),
357            "path:./packages/base"
358        );
359        assert_eq!(
360            normalize_lockfile_artifact(
361                project_root,
362                "v1/artifacts/sample/base/1.0.0/package.praypkg",
363                package_root,
364            ),
365            "v1/artifacts/sample/base/1.0.0/package.praypkg"
366        );
367    }
368}