buffrs/
lock.rs

1// Copyright 2023 Helsing GmbH
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::BTreeMap;
16
17use miette::{Context, IntoDiagnostic, ensure};
18use semver::Version;
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21use tokio::fs;
22use url::Url;
23
24use crate::{
25    ManagedFile,
26    errors::{DeserializationError, FileExistsError, FileNotFound, SerializationError, WriteError},
27    package::{Package, PackageName},
28    registry::RegistryUri,
29};
30
31mod digest;
32pub use digest::{Digest, DigestAlgorithm};
33
34/// File name of the lockfile
35pub const LOCKFILE: &str = "Proto.lock";
36
37/// Captures immutable metadata about a given package
38///
39/// It is used to ensure that future installations will use the exact same dependencies.
40#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
41pub struct LockedPackage {
42    /// The name of the package
43    pub name: PackageName,
44    /// The cryptographic digest of the package contents
45    pub digest: Digest,
46    /// The URI of the registry that contains the package
47    pub registry: RegistryUri,
48    /// The identifier of the repository where the package was published
49    pub repository: String,
50    /// The exact version of the package
51    pub version: Version,
52    /// Names of dependency packages
53    pub dependencies: Vec<PackageName>,
54    /// Count of dependant packages in the current graph
55    ///
56    /// This is used to detect when an entry can be safely removed from the lockfile.
57    pub dependants: usize,
58}
59
60impl LockedPackage {
61    /// Captures the source, version and checksum of a Package for use in reproducible installs
62    pub fn lock(
63        package: &Package,
64        registry: RegistryUri,
65        repository: String,
66        dependants: usize,
67    ) -> Self {
68        Self {
69            name: package.name().to_owned(),
70            registry,
71            repository,
72            digest: package.digest(DigestAlgorithm::SHA256).to_owned(),
73            version: package.version().to_owned(),
74            dependencies: package
75                .manifest
76                .dependencies
77                .iter()
78                .map(|d| d.package.clone())
79                .collect(),
80            dependants,
81        }
82    }
83
84    /// Validates if another LockedPackage matches this one
85    pub fn validate(&self, package: &Package) -> miette::Result<()> {
86        let digest: Digest = DigestAlgorithm::SHA256.digest(&package.tgz);
87
88        #[derive(Error, Debug)]
89        #[error("{property} mismatch - expected {expected}, actual {actual}")]
90        struct ValidationError {
91            property: &'static str,
92            expected: String,
93            actual: String,
94        }
95
96        ensure!(
97            &self.name == package.name(),
98            ValidationError {
99                property: "name",
100                expected: self.name.to_string(),
101                actual: package.name().to_string(),
102            }
103        );
104
105        ensure!(
106            &self.version == package.version(),
107            ValidationError {
108                property: "version",
109                expected: self.version.to_string(),
110                actual: package.version().to_string(),
111            }
112        );
113
114        ensure!(
115            self.digest == digest,
116            ValidationError {
117                property: "digest",
118                expected: self.digest.to_string(),
119                actual: digest.to_string(),
120            }
121        );
122
123        Ok(())
124    }
125}
126
127#[derive(Serialize, Deserialize)]
128struct RawLockfile {
129    version: u16,
130    packages: Vec<LockedPackage>,
131}
132
133/// Captures metadata about currently installed Packages
134///
135/// Used to ensure future installations will deterministically select the exact same packages.
136#[derive(Default)]
137pub struct Lockfile {
138    packages: BTreeMap<PackageName, LockedPackage>,
139}
140
141impl Lockfile {
142    /// Checks if the Lockfile currently exists in the filesystem
143    pub async fn exists() -> miette::Result<bool> {
144        fs::try_exists(LOCKFILE)
145            .await
146            .into_diagnostic()
147            .wrap_err(FileExistsError(LOCKFILE))
148    }
149
150    /// Loads the Lockfile from the current directory
151    pub async fn read() -> miette::Result<Self> {
152        match fs::read_to_string(LOCKFILE).await {
153            Ok(contents) => {
154                let raw: RawLockfile = toml::from_str(&contents)
155                    .into_diagnostic()
156                    .wrap_err(DeserializationError(ManagedFile::Lock))?;
157                Ok(Self::from_iter(raw.packages.into_iter()))
158            }
159            Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => {
160                Err(FileNotFound(LOCKFILE.into()).into())
161            }
162            Err(err) => Err(err).into_diagnostic(),
163        }
164    }
165
166    /// Loads the Lockfile from the current directory, if it exists, otherwise returns an empty one
167    pub async fn read_or_default() -> miette::Result<Self> {
168        if Lockfile::exists().await? {
169            Lockfile::read().await
170        } else {
171            Ok(Lockfile::default())
172        }
173    }
174
175    /// Persists a Lockfile to the filesystem
176    pub async fn write(&self) -> miette::Result<()> {
177        let mut packages: Vec<_> = self
178            .packages
179            .values()
180            .map(|pkg| {
181                let mut locked = pkg.clone();
182                locked.dependencies.sort();
183                locked
184            })
185            .collect();
186
187        packages.sort();
188
189        let raw = RawLockfile {
190            version: 1,
191            packages,
192        };
193
194        fs::write(
195            LOCKFILE,
196            toml::to_string(&raw)
197                .into_diagnostic()
198                .wrap_err(SerializationError(ManagedFile::Lock))?
199                .into_bytes(),
200        )
201        .await
202        .into_diagnostic()
203        .wrap_err(WriteError(LOCKFILE))
204    }
205
206    /// Locates a given package in the Lockfile
207    pub fn get(&self, name: &PackageName) -> Option<&LockedPackage> {
208        self.packages.get(name)
209    }
210}
211
212impl FromIterator<LockedPackage> for Lockfile {
213    fn from_iter<I: IntoIterator<Item = LockedPackage>>(iter: I) -> Self {
214        Self {
215            packages: iter
216                .into_iter()
217                .map(|locked| (locked.name.clone(), locked))
218                .collect(),
219        }
220    }
221}
222
223impl From<Lockfile> for Vec<FileRequirement> {
224    /// Converts lockfile into list of required files
225    ///
226    /// Must return files with a stable order to ensure identical lockfiles lead to identical
227    /// buffrs-cache nix derivations
228    fn from(lock: Lockfile) -> Self {
229        lock.packages.values().map(FileRequirement::from).collect()
230    }
231}
232
233/// A requirement from a lockfile on a specific file being available in order to build the
234/// overall graph. It's expected that when a file is downloaded, it's made available to buffrs
235/// by setting the filename to the digest in whatever download directory.
236#[derive(Serialize, Clone, PartialEq, Eq)]
237pub struct FileRequirement {
238    pub(crate) package: PackageName,
239    pub(crate) url: Url,
240    pub(crate) digest: Digest,
241}
242
243impl FileRequirement {
244    /// URL where the file can be located.
245    pub fn url(&self) -> &Url {
246        &self.url
247    }
248
249    /// Construct new file requirement.
250    pub fn new(
251        url: &RegistryUri,
252        repository: &String,
253        name: &PackageName,
254        version: &Version,
255        digest: &Digest,
256    ) -> Self {
257        let mut url = url.clone();
258        let new_path = format!(
259            "{}/{}/{}/{}-{}.tgz",
260            url.path(),
261            repository,
262            name,
263            name,
264            version
265        );
266
267        url.set_path(&new_path);
268
269        Self {
270            package: name.to_owned(),
271            url: url.into(),
272            digest: digest.clone(),
273        }
274    }
275}
276
277impl From<LockedPackage> for FileRequirement {
278    fn from(package: LockedPackage) -> Self {
279        Self::new(
280            &package.registry,
281            &package.repository,
282            &package.name,
283            &package.version,
284            &package.digest,
285        )
286    }
287}
288
289impl From<&LockedPackage> for FileRequirement {
290    fn from(package: &LockedPackage) -> Self {
291        Self::new(
292            &package.registry,
293            &package.repository,
294            &package.name,
295            &package.version,
296            &package.digest,
297        )
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use std::{collections::BTreeMap, str::FromStr};
304
305    use semver::Version;
306
307    use crate::{package::PackageName, registry::RegistryUri};
308
309    use super::{Digest, DigestAlgorithm, FileRequirement, LockedPackage, Lockfile};
310
311    fn simple_lockfile() -> Lockfile {
312        Lockfile {
313            packages: BTreeMap::from([
314                (
315                    PackageName::new("package1").unwrap(),
316                    LockedPackage {
317                        name: PackageName::new("package1").unwrap(),
318                        digest: Digest::from_parts(
319                            DigestAlgorithm::SHA256,
320                            "c109c6b120c525e6ea7b2db98335d39a3272f572ac86ba7b2d65c765c353c122",
321                        )
322                        .unwrap(),
323                        registry: RegistryUri::from_str("http://my-registry.com").unwrap(),
324                        repository: "my-repo".to_owned(),
325                        version: Version::new(0, 1, 0),
326                        dependencies: vec![],
327                        dependants: 1,
328                    },
329                ),
330                (
331                    PackageName::new("package2").unwrap(),
332                    LockedPackage {
333                        name: PackageName::new("package2").unwrap(),
334                        digest: Digest::from_parts(
335                            DigestAlgorithm::SHA256,
336                            "c109c6b120c525e6ea7b2db98335d39a3272f572ac86ba7b2d65c765c353bce3",
337                        )
338                        .unwrap(),
339                        registry: RegistryUri::from_str("http://my-registry.com").unwrap(),
340                        repository: "my-other-repo".to_owned(),
341                        version: Version::new(0, 2, 0),
342                        dependencies: vec![],
343                        dependants: 1,
344                    },
345                ),
346                (
347                    PackageName::new("package3").unwrap(),
348                    LockedPackage {
349                        name: PackageName::new("package3").unwrap(),
350                        digest: Digest::from_parts(
351                            DigestAlgorithm::SHA256,
352                            "c109c6b120c525e6ea7b2db98335d39a3272f572ac86ba7b2d65c765c353bce3",
353                        )
354                        .unwrap(),
355                        registry: RegistryUri::from_str("http://your-registry.com").unwrap(),
356                        repository: "your-repo".to_owned(),
357                        version: Version::new(0, 2, 0),
358                        dependencies: vec![],
359                        dependants: 1,
360                    },
361                ),
362                (
363                    PackageName::new("package4").unwrap(),
364                    LockedPackage {
365                        name: PackageName::new("package4").unwrap(),
366                        digest: Digest::from_parts(
367                            DigestAlgorithm::SHA256,
368                            "c109c6b120c525e6ea7b2db98335d39a3272f572ac86ba7b2d65c765c353bce3",
369                        )
370                        .unwrap(),
371                        registry: RegistryUri::from_str("http://your-registry.com").unwrap(),
372                        repository: "your-other-repo".to_owned(),
373                        version: Version::new(0, 2, 0),
374                        dependencies: vec![],
375                        dependants: 1,
376                    },
377                ),
378            ]),
379        }
380    }
381
382    #[test]
383    fn stable_file_requirement_order() {
384        let lock = simple_lockfile();
385        let files: Vec<FileRequirement> = lock.into();
386        for _ in 0..30 {
387            let other_files: Vec<FileRequirement> = simple_lockfile().into();
388            assert!(other_files == files)
389        }
390    }
391}