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