Skip to main content

wasm_pkg_core/
lock.rs

1//! Type definitions and functions for working with `wkg.lock` files.
2
3use std::{
4    cmp::Ordering,
5    collections::{BTreeSet, HashMap},
6    ops::{Deref, DerefMut},
7    path::{Path, PathBuf},
8};
9
10use anyhow::{Context, Result};
11use semver::{Version, VersionReq};
12use serde::{Deserialize, Serialize};
13use tokio::{
14    fs::{File, OpenOptions},
15    io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt},
16};
17use wasm_pkg_client::{ContentDigest, PackageRef};
18
19use crate::resolver::{DependencyResolution, DependencyResolutionMap};
20
21/// The default name of the lock file.
22pub const LOCK_FILE_NAME: &str = "wkg.lock";
23/// The version of the lock file for v1
24pub const LOCK_FILE_V1: u64 = 1;
25
26/// Represents a resolved dependency lock file.
27///
28/// This is a TOML file that contains the resolved dependency information from
29/// a previous build.
30#[derive(Debug, serde::Serialize)]
31pub struct LockFile {
32    /// The version of the lock file.
33    ///
34    /// Currently this is always `1`.
35    pub version: u64,
36
37    /// The locked dependencies in the lock file.
38    ///
39    /// This list is sorted by the name of the locked package.
40    pub packages: BTreeSet<LockedPackage>,
41
42    #[serde(skip)]
43    locker: Locker,
44}
45
46impl PartialEq for LockFile {
47    fn eq(&self, other: &Self) -> bool {
48        self.packages == other.packages && self.version == other.version
49    }
50}
51
52impl Eq for LockFile {}
53
54impl LockFile {
55    /// Creates a new lock file from the given packages at the given path. This will create an empty
56    /// file and get an exclusive lock on the file, but will not write the data to the file unless
57    /// [`write`](Self::write) is called.
58    pub async fn new_with_path(
59        packages: impl IntoIterator<Item = LockedPackage>,
60        path: impl AsRef<Path>,
61    ) -> Result<Self> {
62        let locker = Locker::open_rw(path.as_ref()).await?;
63        Ok(Self {
64            version: LOCK_FILE_V1,
65            packages: packages.into_iter().collect(),
66            locker,
67        })
68    }
69
70    /// Loads a lock file from the given path. If readonly is set to false, then an exclusive lock
71    /// will be acquired on the file. This function will block until the lock is acquired.
72    pub async fn load_from_path(path: impl AsRef<Path>, readonly: bool) -> Result<Self> {
73        let mut locker = if readonly {
74            Locker::open_ro(path.as_ref()).await
75        } else {
76            Locker::open_rw(path.as_ref()).await
77        }?;
78        let mut contents = String::new();
79        locker
80            .read_to_string(&mut contents)
81            .await
82            .context("unable to load lock file from path")?;
83        let lock_file: LockFileIntermediate =
84            toml::from_str(&contents).context("unable to parse lock file from path")?;
85        // Ensure version is correct and error if it isn't
86        if lock_file.version != LOCK_FILE_V1 {
87            return Err(anyhow::anyhow!(
88                "unsupported lock file version: {}",
89                lock_file.version
90            ));
91        }
92        // Rewind the file after reading just to be safe. We already do this before writing, but
93        // just in case we add any future logic, we can reset the file here so as to not cause
94        // issues
95        locker
96            .rewind()
97            .await
98            .context("Unable to reset file after reading")?;
99        Ok(lock_file.into_lock_file(locker))
100    }
101
102    /// Creates a lock file from the dependency map. This will create an empty file (if it doesn't
103    /// exist) and get an exclusive lock on the file, but will not write the data to the file unless
104    /// [`write`](Self::write) is called.
105    pub async fn from_dependencies(
106        map: &DependencyResolutionMap,
107        path: impl AsRef<Path>,
108    ) -> Result<LockFile> {
109        let packages = generate_locked_packages(map);
110
111        LockFile::new_with_path(packages, path).await
112    }
113
114    /// A helper for updating the current lock file with the given dependency map. This will clear current
115    /// packages that are not in the dependency map and add new packages that are in the dependency
116    /// map.
117    ///
118    /// This function will not write the data to the file unless [`write`](Self::write) is called.
119    pub fn update_dependencies(&mut self, map: &DependencyResolutionMap) {
120        self.packages.clear();
121        self.packages.extend(generate_locked_packages(map));
122    }
123
124    /// Attempts to load the lock file from the current directory. Most of the time, users of this
125    /// crate should use this function. Right now it just checks for a `wkg.lock` file in the
126    /// current directory, but we could add more resolution logic in the future. If the file is not
127    /// found, a new file is created and a default empty lockfile is returned. This function will
128    /// block until the lock is acquired.
129    pub async fn load(readonly: bool) -> Result<Self> {
130        let lock_path = PathBuf::from(LOCK_FILE_NAME);
131        if !tokio::fs::try_exists(&lock_path).await? {
132            // Create a new lock file if it doesn't exist so we can then open it readonly if that is set
133            let mut temp_lock = Self::new_with_path([], &lock_path).await?;
134            temp_lock.write().await?;
135        }
136        Self::load_from_path(lock_path, readonly).await
137    }
138
139    /// Serializes and writes the lock file
140    pub async fn write(&mut self) -> Result<()> {
141        let contents = toml::to_string_pretty(self)?;
142        // Truncate the file before writing to it
143        self.locker.rewind().await.with_context(|| {
144            format!(
145                "unable to rewind lock file at path {}",
146                self.locker.path.display()
147            )
148        })?;
149        self.locker.set_len(0).await.with_context(|| {
150            format!(
151                "unable to truncate lock file at path {}",
152                self.locker.path.display()
153            )
154        })?;
155
156        self.locker.write_all(
157            b"# This file is automatically generated.\n# It is not intended for manual editing.\n",
158        )
159        .await.with_context(|| format!("unable to write lock file to path {}", self.locker.path.display()))?;
160        self.locker
161            .write_all(contents.as_bytes())
162            .await
163            .with_context(|| {
164                format!(
165                    "unable to write lock file to path {}",
166                    self.locker.path.display()
167                )
168            })?;
169        // Make sure to flush and sync just to be sure the file doesn't drop and the lock is
170        // released too early
171        self.locker.sync_all().await.with_context(|| {
172            format!(
173                "unable to write lock file to path {}",
174                self.locker.path.display()
175            )
176        })
177    }
178
179    /// Resolves a package from the lock file.
180    ///
181    /// Returns `Ok(None)` if the package cannot be resolved.
182    ///
183    /// Fails if the package cannot be resolved and the lock file is not allowed to be updated.
184    pub fn resolve(
185        &self,
186        registry: Option<&str>,
187        package_ref: &PackageRef,
188        requirement: &VersionReq,
189    ) -> Result<Option<&LockedPackageVersion>> {
190        // NOTE(thomastaylor312): Using a btree map so we don't have to keep sorting the vec. The
191        // tradeoff is we have to clone two things here to do the fetch. That tradeoff seems fine to
192        // me, especially because this is used in CLI commands.
193        if let Some(pkg) = self.packages.get(&LockedPackage {
194            name: package_ref.clone(),
195            registry: registry.map(ToString::to_string),
196            versions: vec![],
197        }) {
198            if let Some(locked) = pkg
199                .versions
200                .iter()
201                .find(|locked| &locked.requirement == requirement)
202            {
203                tracing::info!(%package_ref, ?registry, %requirement, resolved_version = %locked.version, "dependency package was resolved by the lock file");
204                return Ok(Some(locked));
205            }
206        }
207
208        tracing::info!(%package_ref, ?registry, %requirement, "dependency package was not in the lock file");
209        Ok(None)
210    }
211}
212
213fn generate_locked_packages(map: &DependencyResolutionMap) -> impl Iterator<Item = LockedPackage> {
214    type PackageKey = (PackageRef, Option<String>);
215    type VersionsMap = HashMap<String, (Version, ContentDigest)>;
216    let mut packages: HashMap<PackageKey, VersionsMap> = HashMap::new();
217
218    for resolution in map.values() {
219        match resolution.key() {
220            Some((id, registry)) => {
221                let pkg = match resolution {
222                    DependencyResolution::Registry(pkg) => pkg,
223                    DependencyResolution::Local(_) => unreachable!(),
224                };
225
226                let prev = packages
227                    .entry((id.clone(), registry.map(str::to_string)))
228                    .or_default()
229                    .insert(
230                        pkg.requirement.to_string(),
231                        (pkg.version.clone(), pkg.digest.clone()),
232                    );
233
234                if let Some((prev, _)) = prev {
235                    // The same requirements should resolve to the same version
236                    assert!(prev == pkg.version)
237                }
238            }
239            None => continue,
240        }
241    }
242
243    packages.into_iter().map(|((name, registry), versions)| {
244        let versions: Vec<LockedPackageVersion> = versions
245            .into_iter()
246            .map(|(requirement, (version, digest))| LockedPackageVersion {
247                requirement: requirement
248                    .parse()
249                    .expect("Version requirement should have been valid. This is programmer error"),
250                version,
251                digest,
252            })
253            .collect();
254
255        LockedPackage {
256            name,
257            registry,
258            versions,
259        }
260    })
261}
262
263/// Represents a locked package in a lock file.
264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
265pub struct LockedPackage {
266    /// The name of the locked package.
267    pub name: PackageRef,
268
269    /// The registry the package was resolved from.
270    // NOTE(thomastaylor312): This is a string instead of using the `Registry` type because clippy
271    // is complaining about it being an interior mutable key type for the btreeset
272    pub registry: Option<String>,
273
274    /// The locked version of a package.
275    ///
276    /// A package may have multiple locked versions if more than one
277    /// version requirement was specified for the package in `wit.toml`.
278    #[serde(alias = "version", default, skip_serializing_if = "Vec::is_empty")]
279    pub versions: Vec<LockedPackageVersion>,
280}
281
282impl Ord for LockedPackage {
283    fn cmp(&self, other: &Self) -> Ordering {
284        if self.name == other.name {
285            self.registry.cmp(&other.registry)
286        } else {
287            self.name.cmp(&other.name)
288        }
289    }
290}
291
292impl PartialOrd for LockedPackage {
293    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
294        Some(self.cmp(other))
295    }
296}
297
298/// Represents version information for a locked package.
299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
300pub struct LockedPackageVersion {
301    /// The version requirement used to resolve this version
302    pub requirement: VersionReq,
303    /// The version the package is locked to.
304    pub version: Version,
305    /// The digest of the package contents.
306    pub digest: ContentDigest,
307}
308
309#[derive(Debug, Deserialize)]
310struct LockFileIntermediate {
311    version: u64,
312
313    #[serde(alias = "package", default, skip_serializing_if = "Vec::is_empty")]
314    packages: BTreeSet<LockedPackage>,
315}
316
317impl LockFileIntermediate {
318    fn into_lock_file(self, locker: Locker) -> LockFile {
319        LockFile {
320            version: self.version,
321            packages: self.packages,
322            locker,
323        }
324    }
325}
326
327/// Used to indicate the access mode of a lock file.
328#[derive(Debug, Copy, Clone, Eq, PartialEq)]
329enum Access {
330    Shared,
331    Exclusive,
332}
333
334/// A wrapper around a lockable file
335#[derive(Debug)]
336struct Locker {
337    file: File,
338    path: PathBuf,
339}
340
341impl Drop for Locker {
342    fn drop(&mut self) {
343        let _ = sys::unlock(&self.file);
344    }
345}
346
347impl Deref for Locker {
348    type Target = File;
349
350    fn deref(&self) -> &Self::Target {
351        &self.file
352    }
353}
354
355impl DerefMut for Locker {
356    fn deref_mut(&mut self) -> &mut Self::Target {
357        &mut self.file
358    }
359}
360
361impl AsRef<File> for Locker {
362    fn as_ref(&self) -> &File {
363        &self.file
364    }
365}
366
367// NOTE(thomastaylor312): These lock file primitives from here on down are mostly copied wholesale
368// from the lock file implementation of cargo-component with some minor modifications to make them
369// work with tokio
370
371impl Locker {
372    // NOTE(thomastaylor312): I am keeping around these try methods for possible later use. Right
373    // now we're ignoring the dead code
374    #[allow(dead_code)]
375    /// Attempts to acquire exclusive access to a file, returning the locked
376    /// version of a file.
377    ///
378    /// This function will create a file at `path` if it doesn't already exist
379    /// (including intermediate directories), and then it will try to acquire an
380    /// exclusive lock on `path`.
381    ///
382    /// If the lock cannot be immediately acquired, `Ok(None)` is returned.
383    ///
384    /// The returned file can be accessed to look at the path and also has
385    /// read/write access to the underlying file.
386    pub async fn try_open_rw(path: impl Into<PathBuf>) -> Result<Option<Self>> {
387        Self::open(
388            path.into(),
389            OpenOptions::new().read(true).write(true).create(true),
390            Access::Exclusive,
391            true,
392        )
393        .await
394    }
395
396    /// Opens exclusive access to a file, returning the locked version of a
397    /// file.
398    ///
399    /// This function will create a file at `path` if it doesn't already exist
400    /// (including intermediate directories), and then it will acquire an
401    /// exclusive lock on `path`.
402    ///
403    /// If the lock cannot be acquired, this function will block until it is
404    /// acquired.
405    ///
406    /// The returned file can be accessed to look at the path and also has
407    /// read/write access to the underlying file.
408    pub async fn open_rw(path: impl Into<PathBuf>) -> Result<Self> {
409        Ok(Self::open(
410            path.into(),
411            OpenOptions::new().read(true).write(true).create(true),
412            Access::Exclusive,
413            false,
414        )
415        .await?
416        .unwrap())
417    }
418
419    #[allow(dead_code)]
420    /// Attempts to acquire shared access to a file, returning the locked version
421    /// of a file.
422    ///
423    /// This function will fail if `path` doesn't already exist, but if it does
424    /// then it will acquire a shared lock on `path`.
425    ///
426    /// If the lock cannot be immediately acquired, `Ok(None)` is returned.
427    ///
428    /// The returned file can be accessed to look at the path and also has read
429    /// access to the underlying file. Any writes to the file will return an
430    /// error.
431    pub async fn try_open_ro(path: impl Into<PathBuf>) -> Result<Option<Self>> {
432        Self::open(
433            path.into(),
434            OpenOptions::new().read(true),
435            Access::Shared,
436            true,
437        )
438        .await
439    }
440
441    /// Opens shared access to a file, returning the locked version of a file.
442    ///
443    /// This function will fail if `path` doesn't already exist, but if it does
444    /// then it will acquire a shared lock on `path`.
445    ///
446    /// If the lock cannot be acquired, this function will block until it is
447    /// acquired.
448    ///
449    /// The returned file can be accessed to look at the path and also has read
450    /// access to the underlying file. Any writes to the file will return an
451    /// error.
452    pub async fn open_ro(path: impl Into<PathBuf>) -> Result<Self> {
453        Ok(Self::open(
454            path.into(),
455            OpenOptions::new().read(true),
456            Access::Shared,
457            false,
458        )
459        .await?
460        .unwrap())
461    }
462
463    async fn open(
464        path: PathBuf,
465        opts: &OpenOptions,
466        access: Access,
467        try_lock: bool,
468    ) -> Result<Option<Self>> {
469        // If we want an exclusive lock then if we fail because of NotFound it's
470        // likely because an intermediate directory didn't exist, so try to
471        // create the directory and then continue.
472        let file = match opts.open(&path).await {
473            Ok(file) => Ok(file),
474            Err(e) if e.kind() == std::io::ErrorKind::NotFound && access == Access::Exclusive => {
475                tokio::fs::create_dir_all(path.parent().unwrap())
476                    .await
477                    .with_context(|| {
478                        format!(
479                            "failed to create parent directories for `{path}`",
480                            path = path.display()
481                        )
482                    })?;
483                opts.open(&path).await
484            }
485            Err(e) => Err(e),
486        }
487        .with_context(|| format!("failed to open `{path}`", path = path.display()))?;
488
489        // Now that the file exists, canonicalize the path for better debuggability.
490        let path = tokio::fs::canonicalize(path)
491            .await
492            .context("failed to canonicalize path")?;
493        let mut lock = Self { file, path };
494
495        // File locking on Unix is currently implemented via `flock`, which is known
496        // to be broken on NFS. We could in theory just ignore errors that happen on
497        // NFS, but apparently the failure mode [1] for `flock` on NFS is **blocking
498        // forever**, even if the "non-blocking" flag is passed!
499        //
500        // As a result, we just skip all file locks entirely on NFS mounts. That
501        // should avoid calling any `flock` functions at all, and it wouldn't work
502        // there anyway.
503        //
504        // [1]: https://github.com/rust-lang/cargo/issues/2615
505        if is_on_nfs_mount(&lock.path) {
506            return Ok(Some(lock));
507        }
508
509        let res = match (access, try_lock) {
510            (Access::Shared, true) => sys::try_lock_shared(&lock.file),
511            (Access::Exclusive, true) => sys::try_lock_exclusive(&lock.file),
512            (Access::Shared, false) => {
513                // We have to move the lock into the thread because it requires exclusive ownership
514                // for dropping. We return it back out after the blocking IO.
515                let (l, res) = tokio::task::spawn_blocking(move || {
516                    let res = sys::lock_shared(&lock.file);
517                    (lock, res)
518                })
519                .await
520                .context("error waiting for blocking IO")?;
521                lock = l;
522                res
523            }
524            (Access::Exclusive, false) => {
525                // We have to move the lock into the thread because it requires exclusive ownership
526                // for dropping. We return it back out after the blocking IO.
527                let (l, res) = tokio::task::spawn_blocking(move || {
528                    let res = sys::lock_exclusive(&lock.file);
529                    (lock, res)
530                })
531                .await
532                .context("error waiting for blocking IO")?;
533                lock = l;
534                res
535            }
536        };
537
538        return match res {
539            Ok(_) => Ok(Some(lock)),
540
541            // In addition to ignoring NFS which is commonly not working we also
542            // just ignore locking on file systems that look like they don't
543            // implement file locking.
544            Err(e) if sys::error_unsupported(&e) => Ok(Some(lock)),
545
546            // Check to see if it was a contention error
547            Err(e) if try_lock && sys::error_contended(&e) => Ok(None),
548
549            Err(e) => Err(anyhow::anyhow!(e).context(format!(
550                "failed to lock file `{path}`",
551                path = lock.path.display()
552            ))),
553        };
554
555        #[cfg(all(target_os = "linux", not(target_env = "musl")))]
556        fn is_on_nfs_mount(path: &Path) -> bool {
557            use std::ffi::CString;
558            use std::mem;
559            use std::os::unix::prelude::*;
560
561            let path = match CString::new(path.as_os_str().as_bytes()) {
562                Ok(path) => path,
563                Err(_) => return false,
564            };
565
566            unsafe {
567                let mut buf: libc::statfs = mem::zeroed();
568                let r = libc::statfs(path.as_ptr(), &mut buf);
569
570                r == 0 && buf.f_type as u32 == libc::NFS_SUPER_MAGIC as u32
571            }
572        }
573
574        #[cfg(any(not(target_os = "linux"), target_env = "musl"))]
575        fn is_on_nfs_mount(_path: &Path) -> bool {
576            false
577        }
578    }
579}
580
581#[cfg(unix)]
582mod sys {
583    use std::io::{Error, Result};
584    use std::os::unix::io::AsRawFd;
585
586    use tokio::fs::File;
587
588    pub(super) fn lock_shared(file: &File) -> Result<()> {
589        flock(file, libc::LOCK_SH)
590    }
591
592    pub(super) fn lock_exclusive(file: &File) -> Result<()> {
593        flock(file, libc::LOCK_EX)
594    }
595
596    pub(super) fn try_lock_shared(file: &File) -> Result<()> {
597        flock(file, libc::LOCK_SH | libc::LOCK_NB)
598    }
599
600    pub(super) fn try_lock_exclusive(file: &File) -> Result<()> {
601        flock(file, libc::LOCK_EX | libc::LOCK_NB)
602    }
603
604    pub(super) fn unlock(file: &File) -> Result<()> {
605        flock(file, libc::LOCK_UN)
606    }
607
608    pub(super) fn error_contended(err: &Error) -> bool {
609        err.raw_os_error() == Some(libc::EWOULDBLOCK)
610    }
611
612    pub(super) fn error_unsupported(err: &Error) -> bool {
613        match err.raw_os_error() {
614            // Unfortunately, depending on the target, these may or may not be the same.
615            // For targets in which they are the same, the duplicate pattern causes a warning.
616            #[allow(unreachable_patterns)]
617            Some(libc::ENOTSUP | libc::EOPNOTSUPP) => true,
618            Some(libc::ENOSYS) => true,
619            _ => false,
620        }
621    }
622
623    #[cfg(not(target_os = "solaris"))]
624    fn flock(file: &File, flag: libc::c_int) -> Result<()> {
625        let ret = unsafe { libc::flock(file.as_raw_fd(), flag) };
626        if ret < 0 {
627            Err(Error::last_os_error())
628        } else {
629            Ok(())
630        }
631    }
632
633    #[cfg(target_os = "solaris")]
634    fn flock(file: &File, flag: libc::c_int) -> Result<()> {
635        // Solaris lacks flock(), so try to emulate using fcntl()
636        let mut flock = libc::flock {
637            l_type: 0,
638            l_whence: 0,
639            l_start: 0,
640            l_len: 0,
641            l_sysid: 0,
642            l_pid: 0,
643            l_pad: [0, 0, 0, 0],
644        };
645        flock.l_type = if flag & libc::LOCK_UN != 0 {
646            libc::F_UNLCK
647        } else if flag & libc::LOCK_EX != 0 {
648            libc::F_WRLCK
649        } else if flag & libc::LOCK_SH != 0 {
650            libc::F_RDLCK
651        } else {
652            panic!("unexpected flock() operation")
653        };
654
655        let mut cmd = libc::F_SETLKW;
656        if (flag & libc::LOCK_NB) != 0 {
657            cmd = libc::F_SETLK;
658        }
659
660        let ret = unsafe { libc::fcntl(file.as_raw_fd(), cmd, &flock) };
661
662        if ret < 0 {
663            Err(Error::last_os_error())
664        } else {
665            Ok(())
666        }
667    }
668}
669
670#[cfg(windows)]
671mod sys {
672    use std::io::{Error, Result};
673    use std::mem;
674    use std::os::windows::io::AsRawHandle;
675
676    use tokio::fs::File;
677    use windows_sys::Win32::Foundation::HANDLE;
678    use windows_sys::Win32::Foundation::{ERROR_INVALID_FUNCTION, ERROR_LOCK_VIOLATION};
679    use windows_sys::Win32::Storage::FileSystem::{
680        LockFileEx, UnlockFile, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY,
681    };
682
683    pub(super) fn lock_shared(file: &File) -> Result<()> {
684        lock_file(file, 0)
685    }
686
687    pub(super) fn lock_exclusive(file: &File) -> Result<()> {
688        lock_file(file, LOCKFILE_EXCLUSIVE_LOCK)
689    }
690
691    pub(super) fn try_lock_shared(file: &File) -> Result<()> {
692        lock_file(file, LOCKFILE_FAIL_IMMEDIATELY)
693    }
694
695    pub(super) fn try_lock_exclusive(file: &File) -> Result<()> {
696        lock_file(file, LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY)
697    }
698
699    pub(super) fn error_contended(err: &Error) -> bool {
700        err.raw_os_error()
701            .map_or(false, |x| x == ERROR_LOCK_VIOLATION as i32)
702    }
703
704    pub(super) fn error_unsupported(err: &Error) -> bool {
705        err.raw_os_error()
706            .map_or(false, |x| x == ERROR_INVALID_FUNCTION as i32)
707    }
708
709    pub(super) fn unlock(file: &File) -> Result<()> {
710        unsafe {
711            let ret = UnlockFile(file.as_raw_handle() as HANDLE, 0, 0, !0, !0);
712            if ret == 0 {
713                Err(Error::last_os_error())
714            } else {
715                Ok(())
716            }
717        }
718    }
719
720    fn lock_file(file: &File, flags: u32) -> Result<()> {
721        unsafe {
722            let mut overlapped = mem::zeroed();
723            let ret = LockFileEx(
724                file.as_raw_handle() as HANDLE,
725                flags,
726                0,
727                !0,
728                !0,
729                &mut overlapped,
730            );
731            if ret == 0 {
732                Err(Error::last_os_error())
733            } else {
734                Ok(())
735            }
736        }
737    }
738}
739
740#[cfg(test)]
741mod tests {
742    use sha2::Digest;
743
744    use super::*;
745
746    #[tokio::test]
747    async fn test_shared_locking() {
748        let tempdir = tempfile::tempdir().expect("failed to create tempdir");
749        let path = tempdir.path().join("test");
750
751        tokio::fs::write(&path, "")
752            .await
753            .expect("failed to write empty file");
754
755        let _locker1 = Locker::open_ro(path.clone())
756            .await
757            .expect("failed to open reader locker");
758        let _locker2 = Locker::open_ro(path.clone())
759            .await
760            .expect("should be able to open a second reader");
761    }
762
763    #[tokio::test]
764    async fn test_exclusive_locking() {
765        let tempdir = tempfile::tempdir().expect("failed to create tempdir");
766        let path = tempdir.path().join("test");
767
768        tokio::fs::write(&path, "")
769            .await
770            .expect("failed to write empty file");
771
772        let locker1 = Locker::open_rw(path.clone())
773            .await
774            .expect("failed to open writer locker");
775        let maybe_locker = Locker::try_open_rw(path.clone())
776            .await
777            .expect("shouldn't fail with a try open");
778        assert!(
779            maybe_locker.is_none(),
780            "Shouldn't be able to open a second writer"
781        );
782
783        let maybe_locker = Locker::try_open_ro(path.clone())
784            .await
785            .expect("shouldn't fail with a try open");
786        assert!(maybe_locker.is_none(), "Shouldn't be able to open a reader");
787
788        // A call to open_rw should block until the first locker is dropped
789        let (tx, rx) = tokio::sync::oneshot::channel();
790        tokio::spawn(async move {
791            let res = Locker::open_rw(path.clone()).await;
792            tx.send(res).expect("failed to send signal");
793        });
794
795        // Sleep here to simulate another process finishing a write
796        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
797        drop(locker1);
798
799        tokio::select! {
800            res = rx => {
801                assert!(res.is_ok(), "failed to open second write locker");
802            }
803            _ = tokio::time::sleep(tokio::time::Duration::from_millis(1000)) => {
804                panic!("timed out waiting for second locker");
805            }
806        }
807    }
808
809    #[tokio::test]
810    async fn test_roundtrip() {
811        let tempdir = tempfile::tempdir().expect("failed to create tempdir");
812        let path = tempdir.path().join(LOCK_FILE_NAME);
813
814        let mut fakehasher = sha2::Sha256::new();
815        fakehasher.update(b"fake");
816
817        let mut expected_deps = BTreeSet::from([
818            LockedPackage {
819                name: "enterprise:holodeck".parse().unwrap(),
820                versions: vec![LockedPackageVersion {
821                    version: "0.1.0".parse().unwrap(),
822                    digest: fakehasher.clone().into(),
823                    requirement: VersionReq::parse("=0.1.0").unwrap(),
824                }],
825                registry: None,
826            },
827            LockedPackage {
828                name: "ds9:holosuite".parse().unwrap(),
829                versions: vec![LockedPackageVersion {
830                    version: "0.1.0".parse().unwrap(),
831                    digest: fakehasher.clone().into(),
832                    requirement: VersionReq::parse("=0.1.0").unwrap(),
833                }],
834                registry: None,
835            },
836        ]);
837
838        let mut lock = LockFile::new_with_path(expected_deps.clone(), &path)
839            .await
840            .expect("Shouldn't fail when creating a new lock file");
841
842        // Write the current file to make sure that works
843        lock.write()
844            .await
845            .expect("Shouldn't fail when writing lock file");
846
847        // Push one more package onto the lock file before writing it
848        let new_package = LockedPackage {
849            name: "defiant:armor".parse().unwrap(),
850            versions: vec![LockedPackageVersion {
851                version: "0.1.0".parse().unwrap(),
852                digest: fakehasher.into(),
853                requirement: VersionReq::parse("=0.1.0").unwrap(),
854            }],
855            registry: None,
856        };
857
858        lock.packages.insert(new_package.clone());
859        expected_deps.insert(new_package);
860
861        // Write again with the same file
862        lock.write()
863            .await
864            .expect("Shouldn't fail when writing lock file");
865
866        // Drop the lock file
867        drop(lock);
868
869        // Now read the lock file again and make sure everything is correct (and we can lock it
870        // properly)
871        let lock = LockFile::load_from_path(&path, false)
872            .await
873            .expect("Shouldn't fail when loading lock file");
874        assert_eq!(
875            lock.packages, expected_deps,
876            "Lock file deps should match expected deps"
877        );
878        assert_eq!(lock.version, LOCK_FILE_V1, "Lock file version should be 1");
879    }
880}