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 Workspace(Workspace),
43
44 Rockspec(RemoteLuaRockspec),
46}
47
48#[derive(Builder)]
52#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
53pub struct Vendor<'a> {
54 target: VendorTarget,
55
56 vendor_dir: PathBuf,
58
59 no_lock: Option<bool>,
61
62 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
120async 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 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
161async 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
177fn 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, ©_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 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}