Skip to main content

tuf/repository/
file_system.rs

1//! Repository implementation backed by a file system.
2
3use {
4    crate::{
5        error::{Error, Result},
6        metadata::{MetadataPath, MetadataVersion, TargetPath},
7        pouf::Pouf,
8        repository::{RepositoryProvider, RepositoryStorage},
9    },
10    futures_io::AsyncRead,
11    futures_util::future::{BoxFuture, FutureExt},
12    futures_util::io::{copy, AllowStdIo},
13    log::debug,
14    std::{
15        collections::HashMap,
16        fs::{DirBuilder, File},
17        io,
18        marker::PhantomData,
19        path::{Path, PathBuf},
20        sync::RwLock,
21    },
22    tempfile::{NamedTempFile, TempPath},
23};
24
25/// A builder to create a repository contained on the local file system.
26pub struct FileSystemRepositoryBuilder<D> {
27    local_path: PathBuf,
28    metadata_prefix: Option<PathBuf>,
29    targets_prefix: Option<PathBuf>,
30    _pouf: PhantomData<D>,
31}
32
33impl<D> FileSystemRepositoryBuilder<D>
34where
35    D: Pouf,
36{
37    /// Create a new repository with the given `local_path` prefix.
38    pub fn new<P: Into<PathBuf>>(local_path: P) -> Self {
39        FileSystemRepositoryBuilder {
40            local_path: local_path.into(),
41            metadata_prefix: None,
42            targets_prefix: None,
43            _pouf: PhantomData,
44        }
45    }
46
47    /// The argument `metadata_prefix` is used to provide an alternate path where metadata is
48    /// stored on the repository. If `None`, this defaults to `/`. For example, if there is a TUF
49    /// repository at `/usr/local/repo/`, but all metadata is stored at `/usr/local/repo/meta/`,
50    /// then passing the arg `Some("meta".into())` would cause `root.json` to be fetched from
51    /// `/usr/local/repo/meta/root.json`.
52    pub fn metadata_prefix<P: Into<PathBuf>>(mut self, metadata_prefix: P) -> Self {
53        self.metadata_prefix = Some(metadata_prefix.into());
54        self
55    }
56
57    /// The argument `targets_prefix` is used to provide an alternate path where targets are
58    /// stored on the repository. If `None`, this defaults to `/`. For example, if there is a TUF
59    /// repository at `/usr/local/repo/`, but all targets are stored at `/usr/local/repo/targets/`,
60    /// then passing the arg `Some("targets".into())` would cause `hello-world` to be fetched from
61    /// `/usr/local/repo/targets/hello-world`.
62    pub fn targets_prefix<P: Into<PathBuf>>(mut self, targets_prefix: P) -> Self {
63        self.targets_prefix = Some(targets_prefix.into());
64        self
65    }
66
67    /// Build a `FileSystemRepository`.
68    pub fn build(self) -> FileSystemRepository<D> {
69        let metadata_path = if let Some(metadata_prefix) = self.metadata_prefix {
70            self.local_path.join(metadata_prefix)
71        } else {
72            self.local_path.clone()
73        };
74
75        let targets_path = if let Some(targets_prefix) = self.targets_prefix {
76            self.local_path.join(targets_prefix)
77        } else {
78            self.local_path.clone()
79        };
80
81        FileSystemRepository {
82            version: RwLock::new(0),
83            metadata_path,
84            targets_path,
85            _pouf: PhantomData,
86        }
87    }
88}
89
90/// A repository contained on the local file system.
91#[derive(Debug)]
92pub struct FileSystemRepository<D>
93where
94    D: Pouf,
95{
96    version: RwLock<u64>,
97    metadata_path: PathBuf,
98    targets_path: PathBuf,
99    _pouf: PhantomData<D>,
100}
101
102impl<D> FileSystemRepository<D>
103where
104    D: Pouf,
105{
106    /// Create a [FileSystemRepositoryBuilder].
107    pub fn builder<P: Into<PathBuf>>(local_path: P) -> FileSystemRepositoryBuilder<D> {
108        FileSystemRepositoryBuilder::new(local_path)
109    }
110
111    /// Create a new repository on the local file system.
112    pub fn new<P: Into<PathBuf>>(local_path: P) -> Self {
113        FileSystemRepositoryBuilder::new(local_path)
114            .metadata_prefix("metadata")
115            .targets_prefix("targets")
116            .build()
117    }
118
119    /// Returns a [FileSystemBatchUpdate] for manipulating this repository. This allows callers to
120    /// stage a number of mutations, and optionally write them all at once.
121    ///
122    /// [FileSystemBatchUpdate] will try to update any changed metadata or targets in a
123    /// single transaction, and will fail if there are any conflict writes, either by directly
124    /// calling [FileSystemRepository::store_metadata], [FileSystemRepository::store_target], or
125    /// another [FileSystemRepository::batch_update].
126    ///
127    /// Warning: The current implementation makes no effort to prevent manipulations of the
128    /// underlying filesystem, either in-process, or by an external process.
129    pub fn batch_update(&self) -> FileSystemBatchUpdate<'_, D> {
130        FileSystemBatchUpdate {
131            initial_parent_version: *self.version.read().unwrap(),
132            parent_repo: self,
133            metadata: RwLock::new(HashMap::new()),
134            targets: RwLock::new(HashMap::new()),
135        }
136    }
137
138    fn metadata_path(&self, meta_path: &MetadataPath, version: MetadataVersion) -> PathBuf {
139        let mut path = self.metadata_path.clone();
140        path.extend(meta_path.components::<D>(version));
141        path
142    }
143
144    fn target_path(&self, target_path: &TargetPath) -> PathBuf {
145        let mut path = self.targets_path.clone();
146        path.extend(target_path.components());
147        path
148    }
149
150    fn fetch_metadata_from_path(
151        &self,
152        meta_path: &MetadataPath,
153        version: MetadataVersion,
154        path: &Path,
155    ) -> BoxFuture<'_, Result<Box<dyn AsyncRead + Send + Unpin + '_>>> {
156        let reader = File::open(path).map_err(|err| {
157            if err.kind() == io::ErrorKind::NotFound {
158                Error::MetadataNotFound {
159                    path: meta_path.clone(),
160                    version,
161                }
162            } else {
163                Error::IoPath {
164                    path: path.to_path_buf(),
165                    err,
166                }
167            }
168        });
169
170        async move {
171            let reader = reader?;
172            let reader: Box<dyn AsyncRead + Send + Unpin> = Box::new(AllowStdIo::new(reader));
173            Ok(reader)
174        }
175        .boxed()
176    }
177
178    fn fetch_target_from_path(
179        &self,
180        target_path: &TargetPath,
181        path: &Path,
182    ) -> BoxFuture<'_, Result<Box<dyn AsyncRead + Send + Unpin + '_>>> {
183        let reader = File::open(path).map_err(|err| {
184            if err.kind() == io::ErrorKind::NotFound {
185                Error::TargetNotFound(target_path.clone())
186            } else {
187                Error::IoPath {
188                    path: path.to_path_buf(),
189                    err,
190                }
191            }
192        });
193
194        async move {
195            let reader = reader?;
196            let reader: Box<dyn AsyncRead + Send + Unpin> = Box::new(AllowStdIo::new(reader));
197            Ok(reader)
198        }
199        .boxed()
200    }
201}
202
203impl<D> RepositoryProvider<D> for FileSystemRepository<D>
204where
205    D: Pouf,
206{
207    fn fetch_metadata<'a>(
208        &'a self,
209        meta_path: &MetadataPath,
210        version: MetadataVersion,
211    ) -> BoxFuture<'a, Result<Box<dyn AsyncRead + Send + Unpin + 'a>>> {
212        let path = self.metadata_path(meta_path, version);
213        self.fetch_metadata_from_path(meta_path, version, &path)
214    }
215
216    fn fetch_target<'a>(
217        &'a self,
218        target_path: &TargetPath,
219    ) -> BoxFuture<'a, Result<Box<dyn AsyncRead + Send + Unpin + 'a>>> {
220        let path = self.target_path(target_path);
221        self.fetch_target_from_path(target_path, &path)
222    }
223}
224
225impl<D> RepositoryStorage<D> for FileSystemRepository<D>
226where
227    D: Pouf,
228{
229    fn store_metadata<'a>(
230        &'a self,
231        meta_path: &MetadataPath,
232        version: MetadataVersion,
233        metadata: &'a mut (dyn AsyncRead + Send + Unpin),
234    ) -> BoxFuture<'a, Result<()>> {
235        let path = self.metadata_path(meta_path, version);
236
237        async move {
238            if path.exists() {
239                debug!("Metadata path exists. Overwriting: {:?}", path);
240            }
241
242            let mut temp_file = AllowStdIo::new(create_temp_file(&path)?);
243            if let Err(err) = copy(metadata, &mut temp_file).await {
244                return Err(Error::IoPath { path, err });
245            }
246
247            // Lock the version counter to prevent other writers from manipulating the repository to
248            // avoid race conditions.
249            let mut version = self.version.write().unwrap();
250
251            temp_file
252                .into_inner()
253                .persist(&path)
254                .map_err(|err| Error::IoPath {
255                    path,
256                    err: err.error,
257                })?;
258
259            // Increment our version since the repository changed.
260            *version += 1;
261
262            Ok(())
263        }
264        .boxed()
265    }
266
267    fn store_target<'a>(
268        &'a self,
269        target_path: &TargetPath,
270        read: &'a mut (dyn AsyncRead + Send + Unpin),
271    ) -> BoxFuture<'a, Result<()>> {
272        let path = self.target_path(target_path);
273
274        async move {
275            if path.exists() {
276                debug!("Target path exists. Overwriting: {:?}", path);
277            }
278
279            let mut temp_file = AllowStdIo::new(create_temp_file(&path)?);
280            if let Err(err) = copy(read, &mut temp_file).await {
281                return Err(Error::IoPath { path, err });
282            }
283
284            let mut version = self.version.write().unwrap();
285
286            temp_file
287                .into_inner()
288                .persist(&path)
289                .map_err(|err| Error::IoPath {
290                    path,
291                    err: err.error,
292                })?;
293
294            // Increment our version since the repository changed.
295            *version += 1;
296
297            Ok(())
298        }
299        .boxed()
300    }
301}
302
303/// [FileSystemBatchUpdate] is a special repository that is designed to write the metadata and
304/// targets to an [FileSystemRepository] in a single batch.
305///
306/// Note: `FileSystemBatchUpdate::commit()` must be called in order to write the metadata and
307/// targets to the [FileSystemRepository]. Otherwise any queued changes will be lost on drop.
308#[derive(Debug)]
309pub struct FileSystemBatchUpdate<'a, D: Pouf> {
310    initial_parent_version: u64,
311    parent_repo: &'a FileSystemRepository<D>,
312    metadata: RwLock<HashMap<PathBuf, TempPath>>,
313    targets: RwLock<HashMap<PathBuf, TempPath>>,
314}
315
316#[derive(Debug, thiserror::Error)]
317pub enum CommitError {
318    /// Conflict occurred during commit.
319    #[error("conflicting change occurred during commit")]
320    Conflict,
321
322    #[error(transparent)]
323    Io(#[from] std::io::Error),
324
325    /// An IO error occurred for a path.
326    #[error("IO error on path {path}")]
327    IoPath {
328        /// Path where the error occurred.
329        path: std::path::PathBuf,
330
331        /// The IO error.
332        #[source]
333        err: io::Error,
334    },
335}
336
337impl<D> FileSystemBatchUpdate<'_, D>
338where
339    D: Pouf,
340{
341    /// Write all the metadata and targets the [FileSystemBatchUpdate] to the source
342    /// [FileSystemRepository] in a single batch operation.
343    ///
344    /// Note: While this function will atomically write each file, it's possible that this could
345    /// fail with part of the files written if we experience a system error during the process.
346    pub async fn commit(self) -> std::result::Result<(), CommitError> {
347        let mut parent_version = self.parent_repo.version.write().unwrap();
348
349        if self.initial_parent_version != *parent_version {
350            return Err(CommitError::Conflict);
351        }
352
353        for (path, tmp_path) in self.targets.into_inner().unwrap() {
354            if path.exists() {
355                debug!("Target path exists. Overwriting: {:?}", path);
356            }
357            tmp_path.persist(&path).map_err(|err| CommitError::IoPath {
358                path,
359                err: err.error,
360            })?;
361        }
362
363        for (path, tmp_path) in self.metadata.into_inner().unwrap() {
364            if path.exists() {
365                debug!("Metadata path exists. Overwriting: {:?}", path);
366            }
367            tmp_path.persist(&path).map_err(|err| CommitError::IoPath {
368                path,
369                err: err.error,
370            })?;
371        }
372
373        // Increment the version because we wrote to it.
374        *parent_version += 1;
375
376        Ok(())
377    }
378}
379
380impl<D> RepositoryProvider<D> for FileSystemBatchUpdate<'_, D>
381where
382    D: Pouf,
383{
384    fn fetch_metadata<'a>(
385        &'a self,
386        meta_path: &MetadataPath,
387        version: MetadataVersion,
388    ) -> BoxFuture<'a, Result<Box<dyn AsyncRead + Send + Unpin + 'a>>> {
389        let path = self.parent_repo.metadata_path(meta_path, version);
390        if let Some(temp_path) = self.metadata.read().unwrap().get(&path) {
391            self.parent_repo
392                .fetch_metadata_from_path(meta_path, version, temp_path)
393        } else {
394            self.parent_repo
395                .fetch_metadata_from_path(meta_path, version, &path)
396        }
397    }
398
399    fn fetch_target<'a>(
400        &'a self,
401        target_path: &TargetPath,
402    ) -> BoxFuture<'a, Result<Box<dyn AsyncRead + Send + Unpin + 'a>>> {
403        let path = self.parent_repo.target_path(target_path);
404        if let Some(temp_path) = self.targets.read().unwrap().get(&path) {
405            self.parent_repo
406                .fetch_target_from_path(target_path, temp_path)
407        } else {
408            self.parent_repo.fetch_target_from_path(target_path, &path)
409        }
410    }
411}
412
413impl<D> RepositoryStorage<D> for FileSystemBatchUpdate<'_, D>
414where
415    D: Pouf,
416{
417    fn store_metadata<'a>(
418        &'a self,
419        meta_path: &MetadataPath,
420        version: MetadataVersion,
421        read: &'a mut (dyn AsyncRead + Send + Unpin),
422    ) -> BoxFuture<'a, Result<()>> {
423        let path = self.parent_repo.metadata_path(meta_path, version);
424
425        async move {
426            let mut temp_file = AllowStdIo::new(create_temp_file(&path)?);
427            if let Err(err) = copy(read, &mut temp_file).await {
428                return Err(Error::IoPath { path, err });
429            }
430            self.metadata
431                .write()
432                .unwrap()
433                .insert(path, temp_file.into_inner().into_temp_path());
434
435            Ok(())
436        }
437        .boxed()
438    }
439
440    fn store_target<'a>(
441        &'a self,
442        target_path: &TargetPath,
443        read: &'a mut (dyn AsyncRead + Send + Unpin),
444    ) -> BoxFuture<'a, Result<()>> {
445        let path = self.parent_repo.target_path(target_path);
446
447        async move {
448            let mut temp_file = AllowStdIo::new(create_temp_file(&path)?);
449            if let Err(err) = copy(read, &mut temp_file).await {
450                return Err(Error::IoPath { path, err });
451            }
452            self.targets
453                .write()
454                .unwrap()
455                .insert(path, temp_file.into_inner().into_temp_path());
456
457            Ok(())
458        }
459        .boxed()
460    }
461}
462
463fn create_temp_file(path: &Path) -> Result<NamedTempFile> {
464    // We want to atomically write the file to make sure clients can never see a partially written
465    // file.  In order to do this, we'll write to a temporary file in the same directory as our
466    // target, otherwise we risk writing the temporary file to one mountpoint, and then
467    // non-atomically copying the file to another mountpoint.
468
469    if let Some(parent) = path.parent() {
470        DirBuilder::new()
471            .recursive(true)
472            .create(parent)
473            .map_err(|err| Error::IoPath {
474                path: parent.to_path_buf(),
475                err,
476            })?;
477        Ok(NamedTempFile::new_in(parent).map_err(|err| Error::IoPath {
478            path: parent.to_path_buf(),
479            err,
480        })?)
481    } else {
482        Ok(NamedTempFile::new_in(".").map_err(|err| Error::IoPath {
483            path: path.to_path_buf(),
484            err,
485        })?)
486    }
487}
488
489#[cfg(test)]
490mod test {
491    use super::*;
492    use crate::error::Error;
493    use crate::metadata::RootMetadata;
494    use crate::pouf::Pouf1;
495    use crate::repository::{fetch_metadata_to_string, fetch_target_to_string, Repository};
496    use assert_matches::assert_matches;
497    use futures_executor::block_on;
498    use futures_util::io::AsyncReadExt;
499    use tempfile;
500
501    #[test]
502    fn file_system_repo_metadata_not_found_error() {
503        block_on(async {
504            let temp_dir = tempfile::Builder::new()
505                .prefix("rust-tuf")
506                .tempdir()
507                .unwrap();
508            let repo = FileSystemRepositoryBuilder::new(temp_dir.path()).build();
509
510            assert_matches!(
511                Repository::<_, Pouf1>::new(repo)
512                    .fetch_metadata::<RootMetadata>(
513                        &MetadataPath::root(),
514                        MetadataVersion::None,
515                        None,
516                        vec![],
517                    )
518                    .await,
519                Err(Error::MetadataNotFound {
520                    path,
521                    version,
522                })
523                if path == MetadataPath::root() && version == MetadataVersion::None
524            );
525        })
526    }
527
528    #[test]
529    fn file_system_repo_targets() {
530        block_on(async {
531            let temp_dir = tempfile::Builder::new()
532                .prefix("rust-tuf")
533                .tempdir()
534                .unwrap();
535            let repo = FileSystemRepositoryBuilder::<Pouf1>::new(temp_dir.path().to_path_buf())
536                .metadata_prefix("meta")
537                .targets_prefix("targs")
538                .build();
539
540            let data: &[u8] = b"like tears in the rain";
541            let path = TargetPath::new("foo/bar/baz").unwrap();
542            repo.store_target(&path, &mut &*data).await.unwrap();
543            assert!(temp_dir
544                .path()
545                .join("targs")
546                .join("foo")
547                .join("bar")
548                .join("baz")
549                .exists());
550
551            let mut buf = Vec::new();
552
553            // Enclose `fetch_target` in a scope to make sure the file is closed.
554            // This is needed for `tempfile` on Windows, which doesn't open the
555            // files in a mode that allows the file to be opened multiple times.
556            {
557                let mut read = repo.fetch_target(&path).await.unwrap();
558                read.read_to_end(&mut buf).await.unwrap();
559                assert_eq!(buf.as_slice(), data);
560            }
561
562            // RepositoryProvider implementations do not guarantee data is not corrupt.
563            let bad_data: &[u8] = b"you're in a desert";
564            repo.store_target(&path, &mut &*bad_data).await.unwrap();
565            let mut read = repo.fetch_target(&path).await.unwrap();
566            buf.clear();
567            read.read_to_end(&mut buf).await.unwrap();
568            assert_eq!(buf.as_slice(), bad_data);
569        })
570    }
571
572    #[test]
573    fn file_system_repo_batch_update() {
574        block_on(async {
575            let temp_dir = tempfile::Builder::new()
576                .prefix("rust-tuf")
577                .tempdir()
578                .unwrap();
579
580            let repo = FileSystemRepositoryBuilder::<Pouf1>::new(temp_dir.path().to_path_buf())
581                .metadata_prefix("meta")
582                .targets_prefix("targs")
583                .build();
584
585            let meta_path = MetadataPath::new("meta").unwrap();
586            let meta_version = MetadataVersion::None;
587            let target_path = TargetPath::new("target").unwrap();
588
589            // First, write some stuff to the repository.
590            let committed_meta = "committed meta";
591            let committed_target = "committed target";
592
593            repo.store_metadata(&meta_path, meta_version, &mut committed_meta.as_bytes())
594                .await
595                .unwrap();
596
597            repo.store_target(&target_path, &mut committed_target.as_bytes())
598                .await
599                .unwrap();
600
601            let batch = repo.batch_update();
602
603            // Make sure we can read back the committed stuff.
604            assert_eq!(
605                fetch_metadata_to_string(&batch, &meta_path, meta_version)
606                    .await
607                    .unwrap(),
608                committed_meta,
609            );
610            assert_eq!(
611                fetch_target_to_string(&batch, &target_path).await.unwrap(),
612                committed_target,
613            );
614
615            // Next, stage some stuff in the batch_update.
616            let staged_meta = "staged meta";
617            let staged_target = "staged target";
618            batch
619                .store_metadata(&meta_path, meta_version, &mut staged_meta.as_bytes())
620                .await
621                .unwrap();
622            batch
623                .store_target(&target_path, &mut staged_target.as_bytes())
624                .await
625                .unwrap();
626
627            // Make sure it got staged.
628            assert_eq!(
629                fetch_metadata_to_string(&batch, &meta_path, meta_version)
630                    .await
631                    .unwrap(),
632                staged_meta,
633            );
634            assert_eq!(
635                fetch_target_to_string(&batch, &target_path).await.unwrap(),
636                staged_target,
637            );
638
639            // Next, drop the batch_update. We shouldn't have written the data back to the
640            // repository.
641            drop(batch);
642
643            assert_eq!(
644                fetch_metadata_to_string(&repo, &meta_path, meta_version)
645                    .await
646                    .unwrap(),
647                committed_meta,
648            );
649            assert_eq!(
650                fetch_target_to_string(&repo, &target_path).await.unwrap(),
651                committed_target,
652            );
653
654            // Do the batch_update again, but this time write the data.
655            let batch = repo.batch_update();
656            batch
657                .store_metadata(&meta_path, meta_version, &mut staged_meta.as_bytes())
658                .await
659                .unwrap();
660            batch
661                .store_target(&target_path, &mut staged_target.as_bytes())
662                .await
663                .unwrap();
664            batch.commit().await.unwrap();
665
666            // Make sure the new data got to the repository.
667            assert_eq!(
668                fetch_metadata_to_string(&repo, &meta_path, meta_version)
669                    .await
670                    .unwrap(),
671                staged_meta,
672            );
673            assert_eq!(
674                fetch_target_to_string(&repo, &target_path).await.unwrap(),
675                staged_target,
676            );
677        })
678    }
679
680    #[test]
681    fn file_system_repo_batch_commit_fails_with_metadata_conflicts() {
682        block_on(async {
683            let temp_dir = tempfile::Builder::new()
684                .prefix("rust-tuf")
685                .tempdir()
686                .unwrap();
687
688            let repo = FileSystemRepository::<Pouf1>::new(temp_dir.path().to_path_buf());
689
690            // commit() fails if we did nothing to the batch, but the repo changed.
691            let batch = repo.batch_update();
692
693            repo.store_metadata(
694                &MetadataPath::new("meta1").unwrap(),
695                MetadataVersion::None,
696                &mut "meta1".as_bytes(),
697            )
698            .await
699            .unwrap();
700
701            assert_matches!(batch.commit().await, Err(CommitError::Conflict));
702
703            // writing to both the repo and the batch should conflict.
704            let batch = repo.batch_update();
705
706            repo.store_metadata(
707                &MetadataPath::new("meta2").unwrap(),
708                MetadataVersion::None,
709                &mut "meta2".as_bytes(),
710            )
711            .await
712            .unwrap();
713
714            batch
715                .store_metadata(
716                    &MetadataPath::new("meta3").unwrap(),
717                    MetadataVersion::None,
718                    &mut "meta3".as_bytes(),
719                )
720                .await
721                .unwrap();
722
723            assert_matches!(batch.commit().await, Err(CommitError::Conflict));
724        })
725    }
726
727    #[test]
728    fn file_system_repo_batch_commit_fails_with_target_conflicts() {
729        block_on(async {
730            let temp_dir = tempfile::Builder::new()
731                .prefix("rust-tuf")
732                .tempdir()
733                .unwrap();
734
735            let repo = FileSystemRepository::<Pouf1>::new(temp_dir.path().to_path_buf());
736
737            // commit() fails if we did nothing to the batch, but the repo changed.
738            let batch = repo.batch_update();
739
740            repo.store_target(
741                &TargetPath::new("target1").unwrap(),
742                &mut "target1".as_bytes(),
743            )
744            .await
745            .unwrap();
746
747            assert_matches!(batch.commit().await, Err(CommitError::Conflict));
748
749            // writing to both the repo and the batch should conflict.
750            let batch = repo.batch_update();
751
752            repo.store_target(
753                &TargetPath::new("target2").unwrap(),
754                &mut "target2".as_bytes(),
755            )
756            .await
757            .unwrap();
758
759            batch
760                .store_target(
761                    &TargetPath::new("target3").unwrap(),
762                    &mut "target3".as_bytes(),
763                )
764                .await
765                .unwrap();
766
767            assert_matches!(batch.commit().await, Err(CommitError::Conflict));
768
769            // multiple batches should conflict.
770            let batch1 = repo.batch_update();
771            let batch2 = repo.batch_update();
772
773            batch1
774                .store_target(
775                    &TargetPath::new("target4").unwrap(),
776                    &mut "target4".as_bytes(),
777                )
778                .await
779                .unwrap();
780
781            batch2
782                .store_target(
783                    &TargetPath::new("target5").unwrap(),
784                    &mut "target5".as_bytes(),
785                )
786                .await
787                .unwrap();
788
789            assert_matches!(batch1.commit().await, Ok(()));
790            assert_matches!(batch2.commit().await, Err(CommitError::Conflict));
791        })
792    }
793}