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