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 read_lockfile(path: &Path) -> PrayResult<Lockfile> {
148    let text = fs::read_to_string(path)?;
149    let lockfile = toml::from_str(&text).map_err(|error| PrayError::Parse {
150        kind: "lockfile",
151        message: error.to_string(),
152    })?;
153    Ok(lockfile)
154}
155
156pub fn relative_lockfile_path(project_root: &Path, path: &Path) -> String {
157    let absolute = if path.is_absolute() {
158        path.to_path_buf()
159    } else {
160        project_root.join(path)
161    };
162    let normalized_root = lexical_normalize_path(project_root);
163    let normalized_absolute = lexical_normalize_path(&absolute);
164    let relative = normalized_absolute
165        .strip_prefix(&normalized_root)
166        .map(Path::to_path_buf)
167        .unwrap_or_else(|_| {
168            if path.is_absolute() {
169                path.to_path_buf()
170            } else {
171                lexical_normalize_path(path)
172            }
173        });
174    format_relative_lockfile_path(&relative)
175}
176
177fn format_relative_lockfile_path(relative: &Path) -> String {
178    let text = relative.to_string_lossy().replace('\\', "/");
179    if text == "." || text.starts_with("./") {
180        text
181    } else {
182        format!("./{text}")
183    }
184}
185
186fn lexical_normalize_path(path: &Path) -> PathBuf {
187    let mut normalized = PathBuf::new();
188    for component in path.components() {
189        match component {
190            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
191            Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR_STR),
192            Component::CurDir => {}
193            Component::ParentDir => {
194                let _ = normalized.pop();
195            }
196            Component::Normal(segment) => normalized.push(segment),
197        }
198    }
199    normalized
200}
201
202fn normalize_lockfile_artifact(project_root: &Path, artifact: &str, package_root: &Path) -> String {
203    if let Some(path_text) = artifact.strip_prefix("path:") {
204        let path = Path::new(path_text);
205        let relative = if path.is_absolute() {
206            relative_lockfile_path(project_root, path)
207        } else {
208            relative_lockfile_path(project_root, package_root)
209        };
210        format!("path:{relative}")
211    } else {
212        artifact.to_string()
213    }
214}
215
216#[allow(clippy::too_many_arguments)]
217pub fn build_lockfile(
218    manifest_hash: String,
219    environment: Option<String>,
220    project_root: &Path,
221    manifest_sources: &[crate::manifest::ManifestSource],
222    manifest_targets: &[crate::manifest::ManifestTarget],
223    rendered: &[RenderedTarget],
224    packages: &[crate::resolve::ResolvedPackage],
225    source_revisions: &std::collections::BTreeMap<String, String>,
226    source_host_keys: &std::collections::BTreeMap<String, String>,
227) -> Lockfile {
228    Lockfile {
229        prayfile_lock: "1".to_string(),
230        spec: "0.1".to_string(),
231        generated_by: format!("pray {}", env!("CARGO_PKG_VERSION")),
232        manifest_hash,
233        environment,
234        source: manifest_sources
235            .iter()
236            .map(|source| LockSource {
237                name: source.name.clone(),
238                kind: source.kind.clone(),
239                url: source.url.clone(),
240                revision: source_revisions.get(&source.name).cloned(),
241                host_key_fingerprint: source_host_keys.get(&source.name).cloned(),
242            })
243            .collect(),
244        package: packages
245            .iter()
246            .map(|package| LockedPackage {
247                name: package.declaration.name.clone(),
248                version: package.spec.version.clone(),
249                source: package.declaration.source.clone(),
250                path: relative_lockfile_path(project_root, &package.root),
251                tree_hash: package.tree_hash.clone(),
252                artifact_hash: package.artifact_hash.clone(),
253                artifact: normalize_lockfile_artifact(
254                    project_root,
255                    &package.artifact,
256                    &package.root,
257                ),
258                exports: package.selected_exports.clone(),
259                dependencies: package
260                    .spec
261                    .dependencies
262                    .iter()
263                    .map(|dependency| dependency.name.clone())
264                    .collect(),
265                signer_fingerprint: package.signer_fingerprint.clone(),
266                upstream: package.upstream.clone(),
267            })
268            .collect(),
269        target: manifest_targets
270            .iter()
271            .map(|target| LockedTarget {
272                name: target.name.clone(),
273                outputs: target.outputs.clone(),
274            })
275            .collect(),
276        managed_span: rendered
277            .iter()
278            .flat_map(|target| target.managed_spans.iter().cloned())
279            .collect(),
280        provisioned: Vec::new(),
281    }
282    .canonicalized()
283}
284
285#[cfg(test)]
286#[path = "lockfile_unit.rs"]
287mod lockfile_unit;