Skip to main content

lux_lib/operations/
vendor.rs

1use std::{
2    collections::HashMap,
3    io::{self, Cursor},
4    path::{Path, PathBuf},
5    process::ExitStatus,
6    sync::Arc,
7};
8
9use bon::Builder;
10use futures::StreamExt;
11use futures::TryStreamExt;
12use itertools::Itertools;
13use miette::Diagnostic;
14use path_slash::PathExt;
15use strum::IntoEnumIterator;
16use thiserror::Error;
17use tokio::io::AsyncWriteExt;
18
19use crate::{
20    build::{resolve_source_dir, BuildBehaviour},
21    config::Config,
22    fs,
23    lockfile::LockedPackageLockType,
24    lua_rockspec::{BuildBackendSpec, RemoteLuaRockspec},
25    operations::{self, PackageInstallSpec, UnpackError},
26    package::{PackageReq, PackageSpec},
27    package_db::{PackageDB, PackageDBError},
28    pipeline::{
29        materialise::{Error as MaterialiseError, Materialise, MaterialisedPackage, PackageSource},
30        resolve::{luarocks_build_backend_name, ResolveError, ResolvePackageDependencies},
31        Artifacts,
32    },
33    project::project_toml::LocalProjectTomlValidationError,
34    rockspec::Rockspec,
35    tree::EntryType,
36    workspace::{Workspace, WorkspaceError},
37};
38
39#[allow(clippy::large_enum_variant)]
40pub enum VendorTarget {
41    /// Vendor dependencies of a Lux workspace
42    Workspace(Workspace),
43
44    /// Vendor dependencies of a Lua RockSpec
45    Rockspec(RemoteLuaRockspec),
46}
47
48/// Vendor a project's dependencies into the specified directory at `<vendor_dir>`.
49/// After this command completes the vendor directory specified by `<vendor_dir>`
50/// will contain all remote sources from dependencies specified.
51#[derive(Builder)]
52#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
53pub struct Vendor<'a> {
54    target: VendorTarget,
55
56    /// The directory in which to vendor the dependencies.
57    vendor_dir: PathBuf,
58
59    /// Ignore the project's lockfile.
60    no_lock: Option<bool>,
61
62    /// Don't delete the `<vendor-dir>` when vendoring,{n}
63    /// but rather keep all existing contents of the vendor directory.
64    no_delete: Option<bool>,
65
66    config: &'a Config,
67}
68
69#[derive(Error, Debug, Diagnostic)]
70pub enum VendorError {
71    #[error(transparent)]
72    #[diagnostic(transparent)]
73    Workspace(#[from] WorkspaceError),
74    #[error("project validation failed")]
75    #[diagnostic(forward(0))]
76    LocalProjectTomlValidation(#[from] LocalProjectTomlValidationError),
77    #[error("error initialising remote package DB")]
78    #[diagnostic(forward(0))]
79    PackageDB(#[from] PackageDBError),
80    #[error(transparent)]
81    #[diagnostic(transparent)]
82    Resolve(#[from] ResolveError),
83    #[error(transparent)]
84    #[diagnostic(transparent)]
85    Download(#[from] MaterialiseError),
86    #[error(transparent)]
87    #[diagnostic(transparent)]
88    Fs(#[from] fs::FsError),
89    #[error("failed to vendor Lua RockSpec:\n{0}")]
90    LuaRockSpec(String),
91    #[error("failed to unpack src.rock")]
92    #[diagnostic(forward(0))]
93    Unpack(#[from] UnpackError),
94    #[error("failed to fetch rock source")]
95    #[diagnostic(forward(0))]
96    FetchSrc(#[from] crate::operations::FetchSrcError),
97    #[error("failed to run `cargo vendor`")]
98    #[diagnostic(help("ensure cargo is installed"))]
99    CargoVendor { source: io::Error },
100    #[error("cargo vendor failed.\nstatus: {status}\nstdout: {stdout}\nstderr: {stderr}")]
101    #[diagnostic(help("check the output for details."))]
102    CargoVendorFailure {
103        status: ExitStatus,
104        stdout: String,
105        stderr: String,
106    },
107}
108
109impl<State> VendorBuilder<'_, State>
110where
111    State: vendor_builder::State + vendor_builder::IsComplete,
112{
113    pub async fn vendor_dependencies(self) -> Result<(), VendorError> {
114        do_vendor_dependencies(self._build()).await
115    }
116}
117
118const CARGO_VENDOR_SUBDIR: &str = "cargo";
119
120/// Resolves the requested packages and downloads (and hashes) their sources.
121async fn resolve_and_download(
122    config: &Config,
123    package_db: PackageDB,
124    install_specs: Artifacts<Vec<PackageInstallSpec>>,
125) -> Result<Vec<MaterialisedPackage>, VendorError> {
126    let Artifacts {
127        regular,
128        build,
129        test,
130    } = install_specs;
131    let mut resolve =
132        ResolvePackageDependencies::new(package_db, config).packages(regular.unwrap_or_default());
133    if let Some(build) = build {
134        resolve = resolve.build_packages(build);
135    }
136    if let Some(test) = test {
137        resolve = resolve.test_packages(test);
138    }
139    let resolved = resolve.resolve().await?;
140
141    let artifacts = Materialise::new(config)
142        .resolved(resolved)
143        .materialise()
144        .await?;
145
146    // The lockfile may contain the same package (name@version) multiple times,
147    // with different constraints.
148    Ok(artifacts
149        .into_iter()
150        .flat_map(|(_, packages)| packages)
151        .flat_map(HashMap::into_values)
152        .unique_by(|pkg| {
153            (
154                pkg.package.spec.name().clone(),
155                pkg.package.spec.version().clone(),
156            )
157        })
158        .collect())
159}
160
161/// Vendors the sources of all packages into `vendor_dir`.
162async fn vendor_sources(
163    vendor_dir: &Path,
164    packages: &[MaterialisedPackage],
165    config: &Config,
166) -> Result<(), VendorError> {
167    futures::stream::iter(
168        packages
169            .iter()
170            .map(|package| vendor_package_sources(vendor_dir, package)),
171    )
172    .buffered(config.max_jobs())
173    .try_collect()
174    .await
175}
176
177/// Cargo-based build backends need their Cargo dependencies vendored too.
178fn cargo_dependencies(
179    packages: &[MaterialisedPackage],
180) -> Vec<(PackageSpec, Option<PathBuf>, Vec<PathBuf>)> {
181    packages
182        .iter()
183        .filter_map(|package| {
184            let rockspec = &package.rockspec;
185            match rockspec.build().current_platform().build_backend {
186                Some(BuildBackendSpec::RustMlua(_) | BuildBackendSpec::RustBinary(_)) => Some((
187                    package.package.spec.to_package(),
188                    rockspec.source().current_platform().unpack_dir.clone(),
189                    rockspec.build().current_platform().copy_directories.clone(),
190                )),
191                _ => None,
192            }
193        })
194        .collect()
195}
196
197async fn do_vendor_dependencies(args: Vendor<'_>) -> Result<(), VendorError> {
198    let vendor_dir = args.vendor_dir;
199    let no_delete = args.no_delete.unwrap_or(false);
200    let no_lock = args.no_lock.unwrap_or(false);
201    let target = args.target;
202    let config = args.config;
203
204    let (package_db, install_specs) = gather_install_specs(no_lock, &target, config).await?;
205    let packages = resolve_and_download(config, package_db, install_specs).await?;
206
207    let cargo_deps = cargo_dependencies(&packages);
208
209    if !no_delete && vendor_dir.exists() {
210        fs::tokio::remove_dir_all(&vendor_dir).await?;
211    }
212
213    let vendor_dir = Arc::new(vendor_dir);
214    vendor_sources(&vendor_dir, &packages, config).await?;
215    vendor_target_cargo_deps(&vendor_dir, &target, config).await?;
216    for (dep, unpack_dir, copy_dirs) in cargo_deps {
217        vendor_package_cargo_deps(&vendor_dir, &dep, &unpack_dir, &copy_dirs, config).await?;
218    }
219    Ok(())
220}
221
222async fn gather_install_specs(
223    no_lock: bool,
224    target: &VendorTarget,
225    config: &Config,
226) -> Result<(PackageDB, Artifacts<Vec<PackageInstallSpec>>), VendorError> {
227    // Resolve against the project's lockfile if present, otherwise fall back to
228    // the remote package DB (e.g. for a project that has not yet generated a lockfile).
229    let package_db = match target {
230        VendorTarget::Workspace(workspace) => match workspace.try_lockfile()? {
231            Some(lockfile) if !no_lock => lockfile.local_pkg_locks().into(),
232            _ => PackageDB::from_config(config).await?,
233        },
234        VendorTarget::Rockspec(_) => PackageDB::from_config(config).await?,
235    };
236
237    let mut install_specs: Artifacts<Vec<PackageInstallSpec>> = Artifacts::default();
238    for lock_type in LockedPackageLockType::iter() {
239        let specs = install_specs
240            .get_mut(lock_type)
241            .get_or_insert_with(Vec::new);
242        match target {
243            VendorTarget::Workspace(workspace) => {
244                for project in workspace.members() {
245                    let toml = project.toml().into_local()?;
246                    push_dependencies(&lock_type, &toml, specs)?;
247                    if lock_type == LockedPackageLockType::Test {
248                        specs.extend(
249                            toml.test()
250                                .current_platform()
251                                .test_dependencies(project)
252                                .iter()
253                                .cloned()
254                                .map(|dep| {
255                                    PackageInstallSpec::new(dep, EntryType::Entrypoint)
256                                        .build_behaviour(BuildBehaviour::Ignore)
257                                        .build()
258                                }),
259                        );
260                    }
261                }
262            }
263            VendorTarget::Rockspec(rockspec) => {
264                push_dependencies(&lock_type, rockspec, specs)?;
265            }
266        }
267    }
268
269    Ok((package_db, install_specs))
270}
271
272fn push_dependencies<R: Rockspec>(
273    lock_type: &LockedPackageLockType,
274    rockspec: &R,
275    install_specs: &mut Vec<PackageInstallSpec>,
276) -> Result<(), LocalProjectTomlValidationError> {
277    let mut dependencies: Vec<PackageReq> = match lock_type {
278        LockedPackageLockType::Regular => rockspec
279            .dependencies()
280            .current_platform()
281            .iter()
282            .map(|dep| dep.package_req().clone())
283            .collect_vec(),
284        LockedPackageLockType::Test => rockspec
285            .test_dependencies()
286            .current_platform()
287            .iter()
288            .map(|dep| dep.package_req().clone())
289            .collect_vec(),
290        LockedPackageLockType::Build => rockspec
291            .build_dependencies()
292            .current_platform()
293            .iter()
294            .map(|dep| dep.package_req().clone())
295            .collect_vec(),
296    };
297    if *lock_type == LockedPackageLockType::Build {
298        if let Some(backend) = luarocks_build_backend_name(rockspec) {
299            dependencies.insert(0, backend.into());
300        }
301    }
302    install_specs.extend(
303        dependencies
304            .into_iter()
305            .unique()
306            .map(|dep| {
307                PackageInstallSpec::new(dep, EntryType::Entrypoint)
308                    .build_behaviour(BuildBehaviour::Ignore)
309                    .build()
310            })
311            .collect_vec(),
312    );
313    Ok(())
314}
315
316#[tracing::instrument(
317    name = "Vendoring source",
318    level = "info",
319    skip_all,
320    fields(
321        package = package.package.spec.name().to_string(),
322        version = package.package.spec.version().to_string(),
323    ),
324)]
325async fn vendor_package_sources(
326    vendor_dir: &Path,
327    package: &MaterialisedPackage,
328) -> Result<(), VendorError> {
329    let rockspec = &package.rockspec;
330    let name = rockspec.package();
331    let version = rockspec.version();
332
333    fs::tokio::create_dir_all(vendor_dir).await?;
334
335    let rockspec_lua_content = rockspec
336        .to_lua_remote_rockspec_string()
337        .map_err(|err| VendorError::LuaRockSpec(err.to_string()))?;
338    let rockspec_path = vendor_dir.join(format!("{}-{}.rockspec", name, version));
339    fs::tokio::write(&rockspec_path, rockspec_lua_content).await?;
340
341    let source_path = vendor_dir.join(format!("{}@{}", name, version));
342    match &package.artifact {
343        PackageSource::SourceDir(path) => {
344            fs::tokio::remove_dir_all(&source_path).await.ok();
345            fs::tokio::copy_dir_all(path, &source_path).await?;
346        }
347        PackageSource::Archive(bytes) => {
348            let rock_path = vendor_dir.join(format!("{}@{}.rock", name, version));
349            let mut file = fs::tokio::create(&rock_path).await?;
350            file.write_all(bytes)
351                .await
352                .map_err(|source| fs::FsError::Write {
353                    path: rock_path,
354                    source,
355                })?;
356        }
357    }
358
359    Ok(())
360}
361
362#[tracing::instrument(name = "Vendoring cargo dependencies", level = "info", skip_all)]
363async fn vendor_target_cargo_deps(
364    vendor_dir: &Path,
365    target: &VendorTarget,
366    config: &Config,
367) -> Result<(), VendorError> {
368    if is_cargo_build_backend(target) {
369        match target {
370            VendorTarget::Workspace(workspace) => {
371                cargo_vendor(vendor_dir, workspace.root().as_path(), config).await
372            }
373            VendorTarget::Rockspec(rockspec) => {
374                let temp_dir = fs::tempfile::tempdir()?;
375                operations::FetchSrc::new(temp_dir.path(), rockspec, config)
376                    .fetch_internal()
377                    .await?;
378                cargo_vendor(
379                    vendor_dir,
380                    &resolve_source_dir(
381                        temp_dir.path(),
382                        rockspec.source().current_platform().unpack_dir.as_deref(),
383                        &rockspec.build().current_platform().copy_directories,
384                    )?,
385                    config,
386                )
387                .await
388            }
389        }
390    } else {
391        Ok(())
392    }
393}
394
395#[tracing::instrument(
396    name = "Vendoring cargo dependencies",
397    level = "info",
398    skip_all,
399    fields(
400        package = package.name().to_string(),
401        version = package.version().to_string(),
402    ),
403)]
404async fn vendor_package_cargo_deps(
405    vendor_dir: &Path,
406    package: &PackageSpec,
407    unpack_dir: &Option<PathBuf>,
408    copy_dirs: &[PathBuf],
409    config: &Config,
410) -> Result<(), VendorError> {
411    let source_dir = vendor_dir.join(format!("{}@{}", package.name(), package.version()));
412    if source_dir.is_dir() {
413        cargo_vendor(
414            vendor_dir,
415            &resolve_source_dir(&source_dir, unpack_dir.as_deref(), copy_dirs)?,
416            config,
417        )
418        .await?;
419    } else if source_dir.is_file() {
420        let temp_dir = fs::tempfile::tempdir()?;
421        extract_source_archive(&source_dir, unpack_dir.as_deref(), temp_dir.path()).await?;
422        cargo_vendor(
423            vendor_dir,
424            &resolve_source_dir(temp_dir.path(), unpack_dir.as_deref(), copy_dirs)?,
425            config,
426        )
427        .await?;
428    }
429
430    Ok(())
431}
432
433fn is_cargo_build_backend(target: &VendorTarget) -> bool {
434    match target {
435        VendorTarget::Workspace(workspace) => workspace.members().iter().any(|project| {
436            project.toml().into_local().is_ok_and(|toml| {
437                matches!(
438                    toml.build().current_platform().build_backend.to_owned(),
439                    Some(BuildBackendSpec::RustMlua(_) | BuildBackendSpec::RustBinary(_))
440                )
441            })
442        }),
443        VendorTarget::Rockspec(rockspec) => matches!(
444            rockspec.build().current_platform().build_backend.to_owned(),
445            Some(BuildBackendSpec::RustMlua(_) | BuildBackendSpec::RustBinary(_))
446        ),
447    }
448}
449
450async fn extract_source_archive(
451    source_file: &Path,
452    unpack_dir: Option<&Path>,
453    dest_dir: &Path,
454) -> Result<(), VendorError> {
455    let bytes = fs::tokio::read(source_file).await?;
456    let mime_type = infer::get(&bytes).map(|file_type| file_type.mime_type());
457    let file_name = source_file
458        .file_name()
459        .map(|file_name| file_name.to_string_lossy())
460        .unwrap_or(source_file.to_slash_lossy())
461        .to_string();
462    operations::unpack::unpack(
463        mime_type,
464        Cursor::new(bytes),
465        unpack_dir.is_none(),
466        file_name,
467        dest_dir,
468    )
469    .await?;
470    Ok(())
471}
472
473async fn cargo_vendor(
474    vendor_dir: &Path,
475    source_dir: &Path,
476    config: &Config,
477) -> Result<(), VendorError> {
478    let cargo_vendor_dir = fs::sync::absolute(vendor_dir.join(CARGO_VENDOR_SUBDIR))?;
479    fs::tokio::create_dir_all(&cargo_vendor_dir).await?;
480
481    let output = config
482        .wrapped_command("cargo", ["vendor", "--locked", "--versioned-dirs"])
483        .arg(&cargo_vendor_dir)
484        .current_dir(source_dir)
485        .output()
486        .await
487        .map_err(|source| VendorError::CargoVendor { source })?;
488
489    if output.status.success() {
490        Ok(())
491    } else {
492        Err(VendorError::CargoVendorFailure {
493            status: output.status,
494            stdout: String::from_utf8_lossy(&output.stdout).into(),
495            stderr: String::from_utf8_lossy(&output.stderr).into(),
496        })
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use tempfile::TempDir;
503
504    use super::*;
505    use crate::{
506        build::BuildBehaviour,
507        lockfile::{LockConstraint, LockedPackage, LockedPackageSpec, OptState, PinnedState},
508        remote_package_source::RemotePackageSource,
509    };
510    #[cfg(feature = "impure_tests")]
511    use {crate::config::ConfigBuilder, assert_fs::prelude::PathCopy, std::path::PathBuf};
512
513    #[cfg(feature = "impure_tests")]
514    fn test_config() -> Config {
515        ConfigBuilder::new()
516            .unwrap()
517            .lua_version(Some(crate::lua_version::LuaVersion::Lua51))
518            .build()
519            .unwrap()
520    }
521
522    fn make_downloaded_package(
523        rockspec: RemoteLuaRockspec,
524        source: RemotePackageSource,
525        artifact: PackageSource,
526        temp_dir: Option<TempDir>,
527    ) -> MaterialisedPackage {
528        let spec = LockedPackageSpec::new(
529            rockspec.package(),
530            rockspec.version(),
531            LockConstraint::Unconstrained,
532            Vec::new(),
533            Vec::new(),
534            &PinnedState::Unpinned,
535            &OptState::Required,
536        );
537        let package = LockedPackage::new(
538            spec,
539            source,
540            None,
541            crate::lockfile::LockedPackageHashes {
542                rockspec: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
543                    .parse()
544                    .unwrap(),
545                source: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
546                    .parse()
547                    .unwrap(),
548            },
549        );
550        MaterialisedPackage {
551            package,
552            rockspec,
553            entry_type: EntryType::Entrypoint,
554            build_behaviour: BuildBehaviour::Ignore,
555            artifact,
556            temp_dir,
557        }
558    }
559
560    #[tokio::test]
561    async fn vendor_source_tree_writes_rockspec_and_source_dir() {
562        let vendor_dir = assert_fs::TempDir::new().unwrap();
563
564        let src_dir = TempDir::new().unwrap();
565        std::fs::write(src_dir.path().join("hello.lua"), "return 'hello'").unwrap();
566        let rockspec = RemoteLuaRockspec::from_source_spec(
567            "rockspec-only-url@1.0.0".parse().unwrap(),
568            crate::lua_rockspec::RockSourceSpec::File(src_dir.path().to_path_buf()),
569            Vec::new(),
570            HashMap::new(),
571        );
572        let artifact = PackageSource::SourceDir(src_dir.path().to_path_buf());
573        let package = make_downloaded_package(
574            rockspec,
575            RemotePackageSource::RockspecContent(String::new()),
576            artifact,
577            Some(src_dir),
578        );
579
580        vendor_package_sources(vendor_dir.path(), &package)
581            .await
582            .unwrap();
583
584        let rockspec_file = vendor_dir.path().join("rockspec-only-url-1.0.0-1.rockspec");
585        assert!(rockspec_file.is_file(), "rockspec not vendored");
586        let rockspec_content = std::fs::read_to_string(&rockspec_file).unwrap();
587        assert!(
588            rockspec_content.contains("rockspec-only-url"),
589            "vendored rockspec is not for rockspec-only-url:\n{rockspec_content}"
590        );
591
592        let source_path = vendor_dir.path().join("rockspec-only-url@1.0.0-1");
593        assert!(source_path.is_dir(), "source tree not vendored");
594        assert!(
595            source_path.join("hello.lua").is_file(),
596            "source tree contents not vendored"
597        );
598    }
599
600    #[tokio::test]
601    async fn vendor_source_tree_replaces_stale_source_dir() {
602        let vendor_dir = assert_fs::TempDir::new().unwrap();
603        let stale_dir = vendor_dir.path().join("rockspec-only-url@1.0.0-1");
604        std::fs::create_dir_all(&stale_dir).unwrap();
605        std::fs::write(stale_dir.join("stale.lua"), "return 'stale'").unwrap();
606
607        let src_dir = TempDir::new().unwrap();
608        std::fs::write(src_dir.path().join("hello.lua"), "return 'hello'").unwrap();
609        let rockspec = RemoteLuaRockspec::from_source_spec(
610            "rockspec-only-url@1.0.0".parse().unwrap(),
611            crate::lua_rockspec::RockSourceSpec::File(src_dir.path().to_path_buf()),
612            Vec::new(),
613            HashMap::new(),
614        );
615        let artifact = PackageSource::SourceDir(src_dir.path().to_path_buf());
616        let package = make_downloaded_package(
617            rockspec,
618            RemotePackageSource::RockspecContent(String::new()),
619            artifact,
620            Some(src_dir),
621        );
622
623        vendor_package_sources(vendor_dir.path(), &package)
624            .await
625            .unwrap();
626
627        assert!(
628            !stale_dir.join("stale.lua").exists(),
629            "stale vendored source was not replaced"
630        );
631        assert!(stale_dir.join("hello.lua").is_file());
632    }
633
634    #[cfg(feature = "impure_tests")]
635    #[tokio::test]
636    async fn vendor_workspace_no_delete_preserves_existing_contents() {
637        let sample_project_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
638            .join("resources/test/sample-projects/busted-with-lockfile/");
639        let temp_dir = assert_fs::TempDir::new().unwrap();
640        temp_dir
641            .copy_from(&sample_project_dir, &["**"])
642            .expect("failed to copy sample project");
643        let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
644        let config = test_config();
645        let vendor_dir = assert_fs::TempDir::new().unwrap();
646        let marker = vendor_dir.path().join("EXISTING_FILE");
647        std::fs::write(&marker, "keep me").unwrap();
648
649        Vendor::new()
650            .target(VendorTarget::Workspace(workspace))
651            .vendor_dir(vendor_dir.to_path_buf())
652            .no_delete(true)
653            .config(&config)
654            .vendor_dependencies()
655            .await
656            .unwrap();
657
658        assert!(
659            marker.is_file(),
660            "no_delete should have preserved existing contents"
661        );
662    }
663}