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::{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
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}
51
52impl Eq for LockFile {}
53
54impl LockFile {
55 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 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 if lock_file.version != LOCK_FILE_V1 {
87 return Err(anyhow::anyhow!(
88 "unsupported lock file version: {}",
89 lock_file.version
90 ));
91 }
92 locker
96 .rewind()
97 .await
98 .context("Unable to reset file after reading")?;
99 Ok(lock_file.into_lock_file(locker))
100 }
101
102 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 pub fn update_dependencies(&mut self, map: &DependencyResolutionMap) {
120 self.packages.clear();
121 self.packages.extend(generate_locked_packages(map));
122 }
123
124 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 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 pub async fn write(&mut self) -> Result<()> {
141 let contents = toml::to_string_pretty(self)?;
142 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 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 pub fn resolve(
185 &self,
186 registry: Option<&str>,
187 package_ref: &PackageRef,
188 requirement: &VersionReq,
189 ) -> Result<Option<&LockedPackageVersion>> {
190 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
265pub struct LockedPackage {
266 pub name: PackageRef,
268
269 pub registry: Option<String>,
273
274 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
300pub struct LockedPackageVersion {
301 pub requirement: VersionReq,
303 pub version: Version,
305 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#[derive(Debug, Copy, Clone, Eq, PartialEq)]
329enum Access {
330 Shared,
331 Exclusive,
332}
333
334#[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
367impl Locker {
372 #[allow(dead_code)]
375 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 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 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 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 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 let path = tokio::fs::canonicalize(path)
491 .await
492 .context("failed to canonicalize path")?;
493 let mut lock = Self { file, path };
494
495 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 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 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 Err(e) if sys::error_unsupported(&e) => Ok(Some(lock)),
545
546 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 #[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 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 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 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 lock.write()
844 .await
845 .expect("Shouldn't fail when writing lock file");
846
847 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 lock.write()
863 .await
864 .expect("Shouldn't fail when writing lock file");
865
866 drop(lock);
868
869 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}