1use 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
21pub const LOCK_FILE_NAME: &str = "wkg.lock";
23pub const LOCK_FILE_V1: u64 = 1;
25
26#[derive(Debug, serde::Serialize)]
31pub struct LockFile {
32 pub version: u64,
36
37 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}
51impl 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 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 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 if lock_file.version != LOCK_FILE_V1 {
95 return Err(anyhow::anyhow!(
96 "unsupported lock file version: {}",
97 lock_file.version
98 ));
99 }
100 locker
104 .rewind()
105 .await
106 .context("Unable to reset file after reading")?;
107 Ok(lock_file.into_lock_file(locker))
108 }
109
110 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 pub fn update_dependencies(&mut self, map: &DependencyResolutionMap) {
128 self.packages.clear();
129 self.packages.extend(generate_locked_packages(map));
130 }
131
132 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 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 pub async fn write(&mut self) -> Result<()> {
149 let contents = toml::to_string_pretty(self)?;
150 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 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 pub fn resolve(
193 &self,
194 registry: Option<&str>,
195 package_ref: &PackageRef,
196 requirement: &VersionReq,
197 ) -> Result<Option<&LockedPackageVersion>> {
198 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
271pub struct LockedPackage {
272 pub name: PackageRef,
274
275 pub registry: Option<String>,
279
280 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
306pub struct LockedPackageVersion {
307 pub requirement: VersionReq,
309 pub version: Version,
311 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#[derive(Debug, Copy, Clone, Eq, PartialEq)]
335enum Access {
336 Shared,
337 Exclusive,
338}
339
340#[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
373impl Locker {
377 #[allow(dead_code)]
380 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 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 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 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 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 let path = tokio::fs::canonicalize(path)
496 .await
497 .context("failed to canonicalize path")?;
498 let mut lock = Self { file, path };
499
500 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 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 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 Err(e) if sys::error_unsupported(&e) => Ok(Some(lock)),
550
551 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 #[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 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 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 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 lock.write()
849 .await
850 .expect("Shouldn't fail when writing lock file");
851
852 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 lock.write()
868 .await
869 .expect("Shouldn't fail when writing lock file");
870
871 drop(lock);
873
874 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}