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    #[serde(default, skip_serializing_if = "Vec::is_empty")]
21    pub provisioned: Vec<ProvisionedFileRecord>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct LockSource {
26    pub name: String,
27    pub kind: String,
28    pub url: String,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub revision: Option<String>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub host_key_fingerprint: Option<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub struct LockedPackage {
37    pub name: String,
38    pub version: String,
39    pub source: Option<String>,
40    pub path: String,
41    pub tree_hash: String,
42    pub artifact_hash: String,
43    pub artifact: String,
44    pub exports: Vec<String>,
45    pub dependencies: Vec<String>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub signer_fingerprint: Option<String>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub upstream: Option<crate::package_upstream::LockedUpstream>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct LockedTarget {
54    pub name: String,
55    pub outputs: Vec<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct ManagedSpanRecord {
60    pub id: String,
61    pub target: String,
62    pub open_line: usize,
63    pub close_line: usize,
64    pub ideal_checksum: String,
65    pub package: String,
66    pub export: String,
67    pub source_checksum: String,
68    pub silenced: bool,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct ProvisionedFileRecord {
73    pub path: String,
74    pub content_hash: String,
75    pub package: String,
76    pub export: String,
77}
78
79impl Lockfile {
80    pub fn canonicalized(&self) -> Self {
81        let mut lockfile = self.clone();
82        lockfile
83            .source
84            .sort_by(|left, right| left.name.cmp(&right.name));
85        lockfile.package.sort_by(|left, right| {
86            left.name
87                .cmp(&right.name)
88                .then(left.source.cmp(&right.source))
89                .then(left.version.cmp(&right.version))
90        });
91        lockfile
92            .target
93            .sort_by(|left, right| left.name.cmp(&right.name));
94        lockfile.managed_span.sort_by(|left, right| {
95            left.target
96                .cmp(&right.target)
97                .then(left.open_line.cmp(&right.open_line))
98                .then(left.id.cmp(&right.id))
99        });
100        lockfile.provisioned.sort_by(|left, right| {
101            left.path
102                .cmp(&right.path)
103                .then(left.package.cmp(&right.package))
104        });
105        lockfile
106    }
107
108    pub fn serialized(&self) -> PrayResult<String> {
109        let bytes = toml::to_string_pretty(&self.canonicalized())
110            .map_err(|error| PrayError::Manifest(error.to_string()))?;
111        Ok(bytes)
112    }
113
114    pub fn file_hash(&self) -> PrayResult<String> {
115        let text = self.serialized()?;
116        Ok(sha256_prefixed(text.as_bytes()))
117    }
118
119    pub fn equivalent_to(&self, other: &Self) -> bool {
120        self == &other.canonicalized()
121    }
122}
123
124pub fn lockfiles_equivalent(canonical: &Lockfile, other: &Lockfile) -> bool {
125    canonical.equivalent_to(other)
126}
127
128pub fn write_lockfile_if_changed(path: &Path, lockfile: &Lockfile) -> PrayResult<()> {
129    let serialized = lockfile.serialized()?;
130    if path.exists() {
131        if let Ok(existing) = fs::read(path) {
132            if existing == serialized.as_bytes() {
133                return Ok(());
134            }
135        }
136    }
137    crate::transaction::write_file(path, serialized)?;
138    Ok(())
139}
140
141pub fn write_lockfile(path: &Path, lockfile: &Lockfile) -> PrayResult<()> {
142    let serialized = lockfile.serialized()?;
143    crate::transaction::write_file(path, serialized)?;
144    Ok(())
145}
146
147pub fn parse_lockfile(text: &str) -> PrayResult<Lockfile> {
148    toml::from_str(text).map_err(|error| PrayError::Parse {
149        kind: "lockfile",
150        message: error.to_string(),
151    })
152}
153
154pub fn serialize_lockfile(lockfile: &Lockfile) -> PrayResult<String> {
155    lockfile.serialized()
156}
157
158pub fn lockfile_hash(lockfile: &Lockfile) -> PrayResult<String> {
159    lockfile.file_hash()
160}
161
162pub fn read_lockfile(path: &Path) -> PrayResult<Lockfile> {
163    parse_lockfile(&fs::read_to_string(path)?)
164}
165
166pub fn relative_lockfile_path(project_root: &Path, path: &Path) -> String {
167    let absolute = if path.is_absolute() {
168        path.to_path_buf()
169    } else {
170        project_root.join(path)
171    };
172    let normalized_root = lexical_normalize_path(project_root);
173    let normalized_absolute = lexical_normalize_path(&absolute);
174    let relative = normalized_absolute
175        .strip_prefix(&normalized_root)
176        .map(Path::to_path_buf)
177        .unwrap_or_else(|_| {
178            if path.is_absolute() {
179                path.to_path_buf()
180            } else {
181                lexical_normalize_path(path)
182            }
183        });
184    format_relative_lockfile_path(&relative)
185}
186
187fn format_relative_lockfile_path(relative: &Path) -> String {
188    let text = relative.to_string_lossy().replace('\\', "/");
189    if text == "." || text.starts_with("./") {
190        text
191    } else {
192        format!("./{text}")
193    }
194}
195
196fn lexical_normalize_path(path: &Path) -> PathBuf {
197    let mut normalized = PathBuf::new();
198    for component in path.components() {
199        match component {
200            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
201            Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR_STR),
202            Component::CurDir => {}
203            Component::ParentDir => {
204                let _ = normalized.pop();
205            }
206            Component::Normal(segment) => normalized.push(segment),
207        }
208    }
209    normalized
210}
211
212fn normalize_lockfile_artifact(project_root: &Path, artifact: &str, package_root: &Path) -> String {
213    if let Some(path_text) = artifact.strip_prefix("path:") {
214        let path = Path::new(path_text);
215        let relative = if path.is_absolute() {
216            relative_lockfile_path(project_root, path)
217        } else {
218            relative_lockfile_path(project_root, package_root)
219        };
220        format!("path:{relative}")
221    } else {
222        artifact.to_string()
223    }
224}
225
226#[allow(clippy::too_many_arguments)]
227pub fn build_lockfile(
228    manifest_hash: String,
229    environment: Option<String>,
230    project_root: &Path,
231    manifest_sources: &[crate::manifest::ManifestSource],
232    manifest_targets: &[crate::manifest::ManifestTarget],
233    rendered: &[RenderedTarget],
234    packages: &[crate::resolve::ResolvedPackage],
235    source_revisions: &std::collections::BTreeMap<String, String>,
236    source_host_keys: &std::collections::BTreeMap<String, String>,
237) -> Lockfile {
238    Lockfile {
239        prayfile_lock: "1".to_string(),
240        spec: "0.1".to_string(),
241        generated_by: format!("pray {}", env!("CARGO_PKG_VERSION")),
242        manifest_hash,
243        environment,
244        source: manifest_sources
245            .iter()
246            .map(|source| LockSource {
247                name: source.name.clone(),
248                kind: source.kind.clone(),
249                url: source.url.clone(),
250                revision: source_revisions.get(&source.name).cloned(),
251                host_key_fingerprint: source_host_keys.get(&source.name).cloned(),
252            })
253            .collect(),
254        package: packages
255            .iter()
256            .map(|package| LockedPackage {
257                name: package.declaration.name.clone(),
258                version: package.spec.version.clone(),
259                source: package.declaration.source.clone(),
260                path: relative_lockfile_path(project_root, &package.root),
261                tree_hash: package.tree_hash.clone(),
262                artifact_hash: package.artifact_hash.clone(),
263                artifact: normalize_lockfile_artifact(
264                    project_root,
265                    &package.artifact,
266                    &package.root,
267                ),
268                exports: package.selected_exports.clone(),
269                dependencies: package
270                    .spec
271                    .dependencies
272                    .iter()
273                    .map(|dependency| dependency.name.clone())
274                    .collect(),
275                signer_fingerprint: package.signer_fingerprint.clone(),
276                upstream: package.upstream.clone(),
277            })
278            .collect(),
279        target: manifest_targets
280            .iter()
281            .map(|target| LockedTarget {
282                name: target.name.clone(),
283                outputs: target.outputs.clone(),
284            })
285            .collect(),
286        managed_span: rendered
287            .iter()
288            .flat_map(|target| target.managed_spans.iter().cloned())
289            .collect(),
290        provisioned: Vec::new(),
291    }
292    .canonicalized()
293}
294
295#[cfg(test)]
296#[path = "lockfile_unit.rs"]
297mod lockfile_unit;