Skip to main content

lux_lib/operations/
fetch.rs

1use crate::build::utils::recursive_copy_dir;
2use crate::config::Config;
3use crate::git::url::RemoteGitUrlParseError;
4use crate::git::GitSource;
5use crate::hash::HasIntegrity;
6use crate::lockfile::RemotePackageSourceUrl;
7use crate::lua_rockspec::RockSourceSpec;
8use crate::package::PackageSpec;
9use crate::rockspec::Rockspec;
10use crate::{fs, operations};
11use auth_git2::{GitAuthenticator, Prompter};
12use bon::Builder;
13use bytes::Bytes;
14use git2::build::RepoBuilder;
15use git2::{Direction, FetchOptions, RemoteCallbacks};
16use miette::Diagnostic;
17use remove_dir_all::remove_dir_all;
18use ssri::Integrity;
19use std::io;
20use std::io::Cursor;
21use std::io::Read;
22use std::path::Path;
23use std::path::PathBuf;
24use thiserror::Error;
25use tracing::span;
26
27use super::DownloadSrcRockError;
28use super::UnpackError;
29
30/// A rocks package source fetcher, providing fine-grained control
31/// over how a package should be fetched.
32#[derive(Builder)]
33#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
34pub struct FetchSrc<'a, R: Rockspec> {
35    #[builder(start_fn)]
36    dest_dir: &'a Path,
37    #[builder(start_fn)]
38    rockspec: &'a R,
39    #[builder(start_fn)]
40    config: &'a Config,
41    #[builder(setters(vis = "pub(crate)"))]
42    source_url: Option<RemotePackageSourceUrl>,
43}
44
45#[derive(Debug)]
46pub(crate) struct RemotePackageSourceMetadata {
47    pub hash: Integrity,
48    pub source_url: RemotePackageSourceUrl,
49}
50
51impl<R: Rockspec, State> FetchSrcBuilder<'_, R, State>
52where
53    State: fetch_src_builder::State + fetch_src_builder::IsComplete,
54{
55    /// Fetch and unpack the source into the `dest_dir`.
56    pub async fn fetch(self) -> Result<(), FetchSrcError> {
57        self.fetch_internal().await?;
58        Ok(())
59    }
60
61    /// Fetch and unpack the source into the `dest_dir`,
62    /// returning the source `Integrity`.
63    pub(crate) async fn fetch_internal(self) -> Result<RemotePackageSourceMetadata, FetchSrcError> {
64        let fetch = self._build();
65        match do_fetch_src(&fetch).await {
66            Err(err)
67                if fetch
68                    .source_url
69                    .is_some_and(|url| matches!(url, RemotePackageSourceUrl::File { .. })) =>
70            {
71                // Don't fall back to downloading .src.rock archives if a local source was specified.
72                Err(err)
73            }
74            Err(err) => match &fetch.rockspec.source().current_platform().source_spec {
75                RockSourceSpec::Git(_) | RockSourceSpec::Url(_) => {
76                    let package = PackageSpec::new(
77                        fetch.rockspec.package().clone(),
78                        fetch.rockspec.version().clone(),
79                    );
80                    let metadata = FetchSrcRock::new(&package, fetch.dest_dir, fetch.config)
81                        .fetch()
82                        .await?;
83                    Ok(metadata)
84                }
85                RockSourceSpec::File(_) => Err(err),
86            },
87            Ok(metadata) => Ok(metadata),
88        }
89    }
90}
91
92#[derive(Error, Debug, Diagnostic)]
93#[non_exhaustive]
94pub enum FetchSrcError {
95    #[error("failed to clone rock source:\n{0}")]
96    #[diagnostic(help("check your network connection and verify the git URL is correct."))]
97    GitClone(#[from] git2::Error),
98    #[error("failed to parse git URL:\n{0}")]
99    #[diagnostic(forward(0))]
100    GitUrlParse(#[from] RemoteGitUrlParseError),
101    #[error(transparent)]
102    #[diagnostic(help("check your network connection."))]
103    Request(#[from] reqwest::Error),
104    #[error(transparent)]
105    #[diagnostic(transparent)]
106    Unpack(#[from] UnpackError),
107    #[error(transparent)]
108    #[diagnostic(transparent)]
109    FetchSrcRock(#[from] FetchSrcRockError),
110    #[error("unable to remove the '.git' directory:\n{0}")]
111    #[diagnostic(help(
112        "check that no process is using the directory and you have write permissions."
113    ))]
114    CleanGitDir(io::Error),
115    #[error("unable to compute hash:\n{0}")]
116    Hash(io::Error),
117    #[error(transparent)]
118    #[diagnostic(transparent)]
119    Fs(#[from] fs::FsError),
120}
121
122/// A rocks package source fetcher, providing fine-grained control
123/// over how a package should be fetched.
124#[derive(Builder)]
125#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
126struct FetchSrcRock<'a> {
127    #[builder(start_fn)]
128    package: &'a PackageSpec,
129    #[builder(start_fn)]
130    dest_dir: &'a Path,
131    #[builder(start_fn)]
132    config: &'a Config,
133}
134
135impl<State> FetchSrcRockBuilder<'_, State>
136where
137    State: fetch_src_rock_builder::State + fetch_src_rock_builder::IsComplete,
138{
139    pub async fn fetch(self) -> Result<RemotePackageSourceMetadata, FetchSrcRockError> {
140        do_fetch_src_rock(self._build()).await
141    }
142}
143
144#[derive(Error, Debug, Diagnostic)]
145#[non_exhaustive]
146#[error(transparent)]
147pub enum FetchSrcRockError {
148    DownloadSrcRock(#[from] DownloadSrcRockError),
149    Unpack(#[from] UnpackError),
150    Io(#[from] io::Error),
151}
152
153/// A no-prompt implementer for auth_git2's prompter
154#[derive(Copy, Clone, Debug)]
155struct NullPrompter;
156
157impl Prompter for NullPrompter {
158    fn prompt_username_password(&mut self, _: &str, _: &git2::Config) -> Option<(String, String)> {
159        None
160    }
161
162    fn prompt_password(&mut self, _: &str, _: &str, _: &git2::Config) -> Option<String> {
163        None
164    }
165
166    fn prompt_ssh_key_passphrase(&mut self, _: &Path, _: &git2::Config) -> Option<String> {
167        None
168    }
169}
170
171async fn do_fetch_src<R: Rockspec>(
172    fetch: &FetchSrc<'_, R>,
173) -> Result<RemotePackageSourceMetadata, FetchSrcError> {
174    let rockspec = fetch.rockspec;
175    let rock_source = rockspec.source().current_platform();
176    let dest_dir = fetch.dest_dir;
177    let config = fetch.config;
178    // prioritise lockfile source, if present
179    let mut source_spec = match &fetch.source_url {
180        Some(source_url) => match source_url {
181            RemotePackageSourceUrl::Git { url, checkout_ref } => RockSourceSpec::Git(GitSource {
182                url: url.parse()?,
183                checkout_ref: Some(checkout_ref.clone()),
184            }),
185            RemotePackageSourceUrl::Url { url } => RockSourceSpec::Url(url.clone()),
186            RemotePackageSourceUrl::File { path } => RockSourceSpec::File(path.clone()),
187        },
188        None => rock_source.source_spec.clone(),
189    };
190    let span = span!(
191        tracing::Level::INFO,
192        "Fetching source",
193        location = source_spec.to_string(),
194    );
195    let _enter = span.enter();
196
197    if let Some(vendor_dir) = config.vendor_dir() {
198        source_spec = match source_spec {
199            // could be a project directory (not vendored) or a local source
200            // or a vendored dependency that we have already resolved
201            RockSourceSpec::File(_) => source_spec,
202            _ => {
203                let pkg_vendor_dir =
204                    vendor_dir.join(format!("{}@{}", rockspec.package(), rockspec.version()));
205                RockSourceSpec::File(pkg_vendor_dir)
206            }
207        }
208    }
209    let metadata = match &source_spec {
210        RockSourceSpec::Git(git) => {
211            let url = git.url.to_string();
212            tracing::debug!(message = format!("Cloning {url}").as_str());
213
214            let resolved_ref = resolve_remote_ref(&url, git.checkout_ref.as_deref(), config);
215
216            if let Some(oid) = resolved_ref {
217                let cache_dir = source_cache_dir(config, &url).join(oid.to_string());
218                if fs::sync::read_dir(&cache_dir).is_ok_and(|mut entries| entries.next().is_some())
219                {
220                    tracing::debug!("using cached git source");
221                    recursive_copy_dir_no_ignore(&cache_dir, dest_dir).await?;
222                    let hash = fetch.dest_dir.hash().await.map_err(FetchSrcError::Hash)?;
223                    let checkout_ref = git.checkout_ref.clone().unwrap_or(oid.to_string());
224                    return Ok(RemotePackageSourceMetadata {
225                        hash,
226                        source_url: RemotePackageSourceUrl::Git { url, checkout_ref },
227                    });
228                }
229            }
230            tracing::debug!("fetching git source");
231
232            let checkout_ref = {
233                let auth = if config.no_prompt() {
234                    GitAuthenticator::default()
235                        .try_password_prompt(0)
236                        .prompt_ssh_key_password(false)
237                        .set_prompter(NullPrompter)
238                } else {
239                    GitAuthenticator::default()
240                };
241                let git_config = git2::Config::open_default()?;
242                let mut callbacks = RemoteCallbacks::new();
243                callbacks.credentials(auth.credentials(&git_config));
244                let mut fetch_options = FetchOptions::new();
245                fetch_options.update_fetchhead(false);
246                fetch_options.remote_callbacks(callbacks);
247                if git.checkout_ref.is_none() {
248                    fetch_options.depth(1);
249                };
250                let mut repo_builder = RepoBuilder::new();
251                repo_builder.fetch_options(fetch_options);
252                let repo = repo_builder.clone(&url, dest_dir)?;
253
254                match &git.checkout_ref {
255                    Some(checkout_ref) => {
256                        let (object, _) = repo.revparse_ext(checkout_ref)?;
257                        repo.checkout_tree(&object, None)?;
258                        checkout_ref.clone()
259                    }
260                    None => {
261                        let head = repo.head()?;
262                        let commit = head.peel_to_commit()?;
263                        commit.id().to_string()
264                    }
265                }
266            };
267            // The .git directory is not deterministic
268            remove_dir_all(dest_dir.join(".git")).map_err(FetchSrcError::CleanGitDir)?;
269
270            if let Some(oid) = resolved_ref {
271                populate_source_cache(
272                    dest_dir,
273                    &source_cache_dir(config, &url).join(oid.to_string()),
274                )
275                .await;
276            }
277
278            let hash = fetch.dest_dir.hash().await.map_err(FetchSrcError::Hash)?;
279            RemotePackageSourceMetadata {
280                hash,
281                source_url: RemotePackageSourceUrl::Git { url, checkout_ref },
282            }
283        }
284        RockSourceSpec::Url(url) => {
285            tracing::debug!(message = format!("📥 Downloading {url}").as_str());
286
287            let cache_path = source_cache_dir(config, url.as_ref()).join("archive");
288            let response = match fs::tokio::read(&cache_path).await {
289                Ok(bytes) => {
290                    tracing::debug!("using cached source archive");
291                    Bytes::from(bytes)
292                }
293                Err(_) => {
294                    tracing::debug!("fetching source archive");
295                    // NOTE: We don't enforce HTTPS when fetching sources because some rockspecs
296                    // have HTTP URLs in `source.url`.
297                    let response = crate::reqwest::http_client(config)?
298                        .get(url.clone())
299                        .send()
300                        .await?
301                        .error_for_status()?
302                        .bytes()
303                        .await?;
304                    write_source_cache_archive(&cache_path, &response).await;
305                    response
306                }
307            };
308            let hash = response.hash().await.map_err(FetchSrcError::Hash)?;
309            let file_name = url
310                .path_segments()
311                .and_then(|mut segments| segments.next_back())
312                .and_then(|name| {
313                    if name.is_empty() {
314                        None
315                    } else {
316                        Some(name.to_string())
317                    }
318                })
319                .unwrap_or(url.to_string());
320            let cursor = Cursor::new(response);
321            let mime_type = infer::get(cursor.get_ref()).map(|file_type| file_type.mime_type());
322            operations::unpack::unpack(
323                mime_type,
324                cursor,
325                rock_source.unpack_dir.is_none(),
326                file_name,
327                dest_dir,
328            )
329            .await?;
330            RemotePackageSourceMetadata {
331                hash,
332                source_url: RemotePackageSourceUrl::Url { url: url.clone() },
333            }
334        }
335        RockSourceSpec::File(path) => {
336            tracing::debug!(message = format!("📋 Copying {}", path.display()).as_str());
337
338            let hash = if path.is_dir() {
339                recursive_copy_dir(&path.to_path_buf(), dest_dir).await?;
340                dest_dir.hash().await.map_err(FetchSrcError::Hash)?
341            } else {
342                let mut file = fs::sync::open(path)?;
343                let mut buffer = Vec::new();
344                file.read_to_end(&mut buffer)
345                    .map_err(|source| fs::FsError::Read {
346                        path: path.to_path_buf(),
347                        source,
348                    })?;
349                let mime_type = infer::get(&buffer).map(|file_type| file_type.mime_type());
350                let file_name = path
351                    .file_name()
352                    .map(|os_str| os_str.to_string_lossy())
353                    .unwrap_or(path.to_string_lossy())
354                    .to_string();
355                operations::unpack::unpack(
356                    mime_type,
357                    file,
358                    rock_source.unpack_dir.is_none(),
359                    file_name,
360                    dest_dir,
361                )
362                .await?;
363                path.hash().await.map_err(FetchSrcError::Hash)?
364            };
365            RemotePackageSourceMetadata {
366                hash,
367                source_url: RemotePackageSourceUrl::File { path: path.clone() },
368            }
369        }
370    };
371    Ok(metadata)
372}
373
374/// Directory in which fetched sources are cached, keyed by source URL.
375fn source_cache_dir(config: &Config, url: &str) -> PathBuf {
376    config.cache_dir().join("sources").join(sanitize_url(url))
377}
378
379fn sanitize_url(url: &str) -> String {
380    url.replace(&[':', '*', '?', '"', '<', '>', '|', '/', '\\'][..], "_")
381}
382
383/// Recursively copy a directory.
384/// Unlike [`crate::build::utils::recursive_copy_dir`], this does not respect ignore files.
385#[tracing::instrument(level = "trace")]
386async fn recursive_copy_dir_no_ignore(src: &Path, dest: &Path) -> Result<(), fs::FsError> {
387    let mut dirs: Vec<PathBuf> = vec![src.to_path_buf()];
388    while let Some(dir) = dirs.pop() {
389        for entry in fs::sync::read_dir(&dir)?.filter_map(Result::ok) {
390            let entry_path = entry.path();
391            let relative_path: PathBuf = pathdiff::diff_paths(&entry_path, src)
392                .unwrap_or_else(|| unreachable!("diff_path with self"));
393            let target = dest.join(relative_path);
394            let file_type = entry.file_type().map_err(|source| fs::FsError::Read {
395                path: entry_path.clone(),
396                source,
397            })?;
398            if file_type.is_dir() {
399                fs::tokio::create_dir_all(&target).await?;
400                dirs.push(entry_path);
401            } else if file_type.is_file() {
402                if let Some(parent) = target.parent() {
403                    fs::tokio::create_dir_all(parent).await?;
404                }
405                fs::tokio::copy(&entry_path, &target).await?;
406            }
407        }
408    }
409    Ok(())
410}
411
412/// Copy the fetched source into the cache, atomically (best-effort).
413#[tracing::instrument(level = "trace")]
414async fn populate_source_cache(dest_dir: &Path, cache_dir: &Path) {
415    let Some(parent) = cache_dir.parent() else {
416        return;
417    };
418    if fs::tokio::create_dir_all(parent).await.is_err() {
419        return;
420    }
421    let temp = parent.join(format!(
422        ".tmp-{}",
423        cache_dir.file_name().unwrap_or_default().to_string_lossy()
424    ));
425    if recursive_copy_dir_no_ignore(dest_dir, &temp)
426        .await
427        .and_then(|_| fs::sync::rename(&temp, cache_dir))
428        .is_err()
429    {
430        let _ = remove_dir_all(&temp);
431        tracing::debug!("failed to populate the source cache");
432    }
433}
434
435/// Write the downloaded archive to the cache, atomically (best-effort).
436#[tracing::instrument(level = "trace")]
437async fn write_source_cache_archive(path: &Path, contents: &Bytes) {
438    let Some(parent) = path.parent() else {
439        return;
440    };
441    if fs::tokio::create_dir_all(parent).await.is_err() {
442        return;
443    }
444    let temp = parent.join(format!(
445        ".tmp-{}",
446        path.file_name().unwrap_or_default().to_string_lossy()
447    ));
448    if fs::tokio::write(&temp, contents).await.is_err()
449        || fs::tokio::rename(&temp, path).await.is_err()
450    {
451        let _ = fs::tokio::remove_file(&temp).await;
452        tracing::debug!("failed to write the source cache");
453    }
454}
455
456/// Resolve a git `checkout_ref` to an immutable commit SHA without cloning,
457/// using the remote's ref advertisement (equivalent to `git ls-remote`).
458/// Returns `None` if the ref cannot be resolved.
459#[tracing::instrument(level = "trace")]
460fn resolve_remote_ref(url: &str, checkout_ref: Option<&str>, config: &Config) -> Option<git2::Oid> {
461    if let Some(reference) = checkout_ref {
462        if let Ok(oid) = git2::Oid::from_str(reference) {
463            return Some(oid);
464        }
465    }
466    let result: Result<git2::Oid, git2::Error> = (|| {
467        let auth = if config.no_prompt() {
468            GitAuthenticator::default()
469                .try_password_prompt(0)
470                .prompt_ssh_key_password(false)
471                .set_prompter(NullPrompter)
472        } else {
473            GitAuthenticator::default()
474        };
475        let git_config = git2::Config::open_default()?;
476        let mut callbacks = RemoteCallbacks::new();
477        callbacks.credentials(auth.credentials(&git_config));
478        let tempdir = fs::tempfile::tempdir().map_err(|err| {
479            git2::Error::from_str(&format!("unable to create temporary directory: {err}"))
480        })?;
481        let repo = git2::Repository::init_bare(tempdir.path())?;
482        let mut remote = repo.remote_anonymous(url)?;
483        let connection = remote.connect_auth(Direction::Fetch, Some(callbacks), None)?;
484        let refs = connection.list()?;
485        let oid = match checkout_ref {
486            Some(reference) => {
487                let candidates = [
488                    reference.to_string(),
489                    format!("refs/heads/{reference}"),
490                    format!("refs/tags/{reference}"),
491                ];
492                refs.iter()
493                    .find(|head| candidates.iter().any(|c| c.as_str() == head.name()))
494                    .map(|head| head.oid())
495            }
496            None => refs
497                .iter()
498                .find(|head| head.name() == "HEAD")
499                .map(|head| head.oid()),
500        };
501        oid.ok_or_else(|| git2::Error::from_str("no matching ref advertised"))
502    })();
503    result.ok()
504}
505
506async fn do_fetch_src_rock(
507    fetch: FetchSrcRock<'_>,
508) -> Result<RemotePackageSourceMetadata, FetchSrcRockError> {
509    let package = fetch.package;
510    let span = span!(
511        tracing::Level::INFO,
512        "Fetching src.rock",
513        package = package.to_string(),
514    );
515    let _enter = span.enter();
516
517    let dest_dir = fetch.dest_dir;
518    let config = fetch.config;
519    let src_rock = operations::download_src_rock(package, config.server(), fetch.config).await?;
520    let hash = src_rock.bytes.hash().await?;
521    let cursor = Cursor::new(src_rock.bytes);
522    let mime_type = infer::get(cursor.get_ref()).map(|file_type| file_type.mime_type());
523    operations::unpack::unpack(mime_type, cursor, true, src_rock.file_name, dest_dir).await?;
524    Ok(RemotePackageSourceMetadata {
525        hash,
526        source_url: RemotePackageSourceUrl::Url { url: src_rock.url },
527    })
528}
529
530#[cfg(test)]
531mod tests {
532    use std::io::Write;
533
534    use assert_fs::prelude::*;
535    use httptest::{matchers::request, responders::status_code, Expectation, Server};
536    use serial_test::serial;
537
538    use crate::config::ConfigBuilder;
539    use crate::lua_rockspec::RemoteLuaRockspec;
540
541    use super::*;
542
543    fn source_zip() -> Vec<u8> {
544        let mut cursor = std::io::Cursor::new(Vec::new());
545        let mut zip = zip::ZipWriter::new(&mut cursor);
546        zip.start_file("test.lua", zip::write::SimpleFileOptions::default())
547            .unwrap();
548        zip.write_all(b"return 1").unwrap();
549        zip.finish().unwrap();
550        cursor.into_inner()
551    }
552
553    fn test_config(cache_dir: std::path::PathBuf) -> Config {
554        ConfigBuilder::new()
555            .unwrap()
556            .cache_dir(Some(cache_dir))
557            .no_progress(Some(true))
558            .build()
559            .unwrap()
560    }
561
562    #[tokio::test]
563    #[serial]
564    async fn fetch_url_source_hits_cache() {
565        let cache_dir = assert_fs::TempDir::new().unwrap().to_path_buf();
566        let server = Server::run();
567        server.expect(
568            // We only allow one call, so the second one has to hit the cache or fail
569            Expectation::matching(request::path("/source.zip"))
570                .times(1)
571                .respond_with(status_code(200).body(source_zip())),
572        );
573        let url = server.url_str("/source.zip");
574        let rockspec = RemoteLuaRockspec::new(&format!(
575            r#"
576rockspec_format = '3.0'
577package = 'cached-package'
578version = '1.0-1'
579description = {{ summary = 'test package' }}
580source = {{ url = '{url}', dir = 'source' }}
581build = {{ type = 'builtin', modules = {{ ['test'] = 'source/test.lua' }} }}
582"#
583        ))
584        .unwrap();
585        let config = test_config(cache_dir);
586
587        let dest_dir = assert_fs::TempDir::new().unwrap();
588        let first = FetchSrc::new(dest_dir.path(), &rockspec, &config)
589            .fetch_internal()
590            .await
591            .unwrap();
592
593        let dest_dir = assert_fs::TempDir::new().unwrap();
594        let second = FetchSrc::new(dest_dir.path(), &rockspec, &config)
595            .fetch_internal()
596            .await
597            .unwrap();
598
599        assert_eq!(first.hash, second.hash);
600    }
601
602    #[tokio::test]
603    #[serial]
604    async fn resolves_git_ref_to_oid_without_cloning() {
605        let repo_dir = assert_fs::TempDir::new().unwrap();
606        let bare_path = repo_dir.path().join("repo.git");
607        let (commit_id, default_branch) = {
608            let bare = git2::Repository::init_bare(&bare_path).unwrap();
609            let signature = git2::Signature::now("test", "test@example.com").unwrap();
610            let tree_id = {
611                let mut builder = bare.treebuilder(None).unwrap();
612                let blob = bare.blob(b"hello").unwrap();
613                builder.insert("hello.txt", blob, 0o100644).unwrap();
614                builder.write().unwrap()
615            };
616            let tree = bare.find_tree(tree_id).unwrap();
617            let commit_id = bare
618                .commit(Some("HEAD"), &signature, &signature, "init", &tree, &[])
619                .unwrap();
620            let head_ref = bare.find_reference("HEAD").unwrap();
621            let default_branch = head_ref
622                .symbolic_target()
623                .unwrap()
624                .unwrap()
625                .strip_prefix("refs/heads/")
626                .unwrap()
627                .to_string();
628            (commit_id, default_branch)
629        };
630
631        let url = format!("file://{}", bare_path.display());
632        let config = test_config(assert_fs::TempDir::new().unwrap().to_path_buf());
633
634        assert_eq!(
635            resolve_remote_ref(&url, Some(&default_branch), &config).unwrap(),
636            commit_id
637        );
638        assert_eq!(
639            resolve_remote_ref(&url, Some(&commit_id.to_string()), &config).unwrap(),
640            commit_id
641        );
642        assert_eq!(resolve_remote_ref(&url, None, &config).unwrap(), commit_id);
643    }
644
645    #[tokio::test]
646    #[serial]
647    async fn copy_dir_recursive_preserves_contents() {
648        let src = assert_fs::TempDir::new().unwrap();
649        src.child("sub").create_dir_all().unwrap();
650        src.child("file.txt").write_str("hello").unwrap();
651        src.child("sub/nested.txt").write_str("world").unwrap();
652        let dest = assert_fs::TempDir::new().unwrap();
653        let dest_path = dest.path().join("copy");
654        recursive_copy_dir_no_ignore(src.path(), &dest_path)
655            .await
656            .unwrap();
657        assert_eq!(
658            fs::sync::read_to_string(dest_path.join("file.txt")).unwrap(),
659            "hello"
660        );
661        assert_eq!(
662            fs::sync::read_to_string(dest_path.join("sub/nested.txt")).unwrap(),
663            "world"
664        );
665    }
666}