1use crate::fs;
2use crate::lockfile::LockedPackage;
3use crate::luarocks;
4use crate::luarocks::rock_manifest::DirOrFileEntry;
5use crate::luarocks::rock_manifest::RockManifest;
6use crate::luarocks::rock_manifest::RockManifestBin;
7use crate::luarocks::rock_manifest::RockManifestConf;
8use crate::luarocks::rock_manifest::RockManifestDoc;
9use crate::luarocks::rock_manifest::RockManifestLib;
10use crate::luarocks::rock_manifest::RockManifestLua;
11use crate::luarocks::rock_manifest::RockManifestRoot;
12use crate::project;
13use crate::target::c_dylib_extension;
14use crate::tree;
15use crate::tree::InstallTree;
16use crate::tree::Tree;
17use bon::Builder;
18use clean_path::Clean;
19use itertools::Itertools;
20use miette::Diagnostic;
21use std::collections::hash_map::Entry;
22use std::collections::HashMap;
23use std::collections::VecDeque;
24use std::fs::File;
25use std::io;
26use std::io::Read;
27use std::io::Write;
28use std::path::Path;
29use std::path::PathBuf;
30use thiserror::Error;
31use walkdir::WalkDir;
32use zip::write::SimpleFileOptions;
33use zip::ZipWriter;
34
35#[cfg(unix)]
36use std::os::unix::fs::PermissionsExt;
37
38#[derive(Builder)]
40#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
41pub struct Pack {
42 #[builder(start_fn)]
43 dest_dir: PathBuf,
44 #[builder(start_fn)]
45 tree: Tree,
46 #[builder(start_fn)]
47 package: LockedPackage,
48}
49
50impl<State> PackBuilder<State>
51where
52 State: pack_builder::State + pack_builder::IsComplete,
53{
54 pub async fn pack(self) -> Result<PathBuf, PackError> {
55 do_pack(self._build()).await
56 }
57}
58
59#[derive(Error, Debug, Diagnostic)]
60#[error("failed to pack rock")]
61pub enum PackError {
62 Zip(#[from] zip::result::ZipError),
63 Io(#[from] io::Error),
64 #[error(transparent)]
65 #[diagnostic(transparent)]
66 Fs(#[from] fs::Error),
67 Walkdir(#[from] walkdir::Error),
68 #[error(transparent)]
69 #[diagnostic(transparent)]
70 Tree(#[from] tree::Error),
71 #[error("expected a `package.rockspec` in the package root.")]
72 MissingRockspec,
73}
74
75async fn do_pack(args: Pack) -> Result<PathBuf, PackError> {
76 let package = args.package;
77 let tree = args.tree;
78 let layout = tree.layout_for(&package.spec);
79 let suffix = if contains_dylib(&layout.lib) || contains_dylib(&layout.etc.join("parser")) {
80 format!("{}.rock", luarocks::current_platform_luarocks_identifier())
81 } else {
82 "all.rock".into()
83 };
84 let file_name = format!("{}-{}.{}", package.name(), package.version(), suffix);
85 let temp_file_name = format!("{}-{}.{}.part", package.name(), package.version(), suffix);
86 let temp_output_path = args.dest_dir.join(temp_file_name);
87 let output_path = args.dest_dir.join(file_name);
88 let file = fs::sync::create(&temp_output_path)?;
89 let mut zip = ZipWriter::new(file);
90
91 let lua_entries = add_rock_entries(&mut zip, &layout.src, "lua".into())?;
92 let lib_entries = add_rock_entries(&mut zip, &layout.lib, "lib".into())?;
93 let doc_entries = add_rock_entries(&mut zip, &layout.doc, "doc".into())?;
94 let conf_entries = add_rock_entries(&mut zip, &layout.conf, "conf".into())?;
95 let temp_root_dir = fs::tempfile::tempdir()?;
97 project::copy_dir(&layout.etc, temp_root_dir.path()).await?;
98 let doc = temp_root_dir.path().join("doc");
100 if doc.is_dir() {
101 fs::tokio::remove_dir_all(&doc).await?;
102 }
103 let conf = temp_root_dir.path().join("conf");
104 if conf.is_dir() {
105 fs::tokio::remove_dir_all(&conf).await?;
106 }
107 if !layout.rockspec_path().is_file() {
110 return Err(PackError::MissingRockspec);
111 }
112 let packed_rockspec_name = format!("{}-{}.rockspec", package.name(), package.version());
113 let renamed_rockspec_entry = temp_root_dir.path().join(packed_rockspec_name);
114 fs::tokio::copy(layout.rockspec_path(), &renamed_rockspec_entry).await?;
115 let root_entries = add_rock_entries(&mut zip, temp_root_dir.path(), "".into())?;
116 let mut bin_entries = HashMap::new();
117 let lockfile = tree.lockfile()?;
118 for relative_binary_path in lockfile
119 .binaries(&package.id())
120 .into_iter()
121 .flat_map(|binaries| binaries.iter())
122 {
123 if let Some(binary_name) = relative_binary_path.clean().file_name() {
124 let binary_path = tree.bin().join(binary_name);
125 if binary_path.is_file() {
126 let (path, digest) =
127 add_rock_entry(&mut zip, binary_path, &tree.bin(), Path::new("bin"))?;
128 bin_entries.insert(path, digest);
129 }
130 }
131 }
132 let rock_manifest = RockManifest {
133 lua: RockManifestLua {
134 entries: lua_entries,
135 },
136 lib: RockManifestLib {
137 entries: lib_entries,
138 },
139 doc: RockManifestDoc {
140 entries: doc_entries,
141 },
142 conf: RockManifestConf {
143 entries: conf_entries,
144 },
145 root: RockManifestRoot {
146 entries: root_entries,
147 },
148 bin: RockManifestBin {
149 entries: bin_entries,
150 },
151 };
152 let manifest_str = rock_manifest.to_lua_string();
153 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
154 zip.start_file("rock_manifest", options)?;
155 zip.write_all(manifest_str.as_bytes())?;
156 fs::tokio::rename(&temp_output_path, &output_path).await?;
157 Ok(output_path)
158}
159
160fn contains_dylib(dir: &Path) -> bool {
161 if !dir.is_dir() {
162 return false;
163 }
164 let dylib_extensions = [c_dylib_extension(), std::env::consts::DLL_EXTENSION];
167 WalkDir::new(dir).into_iter().any(|entry| {
168 entry.is_ok_and(|entry| {
169 let file = entry.into_path();
170 file.is_file()
171 && file
172 .extension()
173 .map(|ext| ext.to_string_lossy().into_owned())
174 .is_some_and(|ext| dylib_extensions.contains(&ext.as_str()))
175 })
176 })
177}
178
179fn add_rock_entries(
180 zip: &mut ZipWriter<File>,
181 source_dir: &Path,
182 zip_dir: PathBuf,
183) -> Result<HashMap<PathBuf, DirOrFileEntry>, PackError> {
184 let mut result = HashMap::new();
185 if source_dir.is_dir() {
186 for file in WalkDir::new(source_dir).into_iter().filter_map_ok(|entry| {
187 let file = entry.into_path();
188 if file.is_file() {
189 Some(file)
190 } else {
191 None
192 }
193 }) {
194 let file = file?;
195 let (relative_path, digest) = add_rock_entry(zip, file, source_dir, &zip_dir)?;
196 add_dir_or_file_entry(&mut result, &relative_path, digest);
197 }
198 }
199 Ok(result)
200}
201
202fn add_rock_entry(
203 zip: &mut ZipWriter<File>,
204 file: PathBuf,
205 source_dir: &Path,
206 zip_dir: &Path,
207) -> Result<(PathBuf, String), PackError> {
208 let relative_path: PathBuf = file
209 .strip_prefix(source_dir)
210 .unwrap_or(file.as_path())
211 .to_path_buf();
212 let mut f = fs::sync::open(file)?;
213 let mut buffer = Vec::new();
214 f.read_to_end(&mut buffer)?;
215 let digest = md5::compute(&buffer);
216
217 #[cfg(target_family = "unix")]
218 let options = SimpleFileOptions::default()
219 .compression_method(zip::CompressionMethod::Stored)
220 .unix_permissions(f.metadata()?.permissions().mode());
221 #[cfg(target_family = "windows")]
222 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
223
224 zip.start_file(zip_dir.join(&relative_path).to_string_lossy(), options)?;
225 zip.write_all(&buffer)?;
226 Ok((relative_path, format!("{digest:x}")))
227}
228
229fn add_dir_or_file_entry(
230 dir_map: &mut HashMap<PathBuf, DirOrFileEntry>,
231 relative_path: &Path,
232 digest: String,
233) {
234 let mut components = relative_path
235 .components()
236 .filter_map(|component| match component {
237 std::path::Component::Normal(path) => Some(PathBuf::from(path)),
238 _ => None,
239 })
240 .collect::<VecDeque<_>>();
241 match &components.len() {
242 n if *n > 1 => {
243 if let Some(first_dir) = components.pop_front() {
244 let remainder = components.iter().collect::<PathBuf>();
245 match dir_map.entry(first_dir) {
246 Entry::Occupied(mut entry) => match entry.get_mut() {
247 DirOrFileEntry::DirEntry(entries) => {
248 add_dir_or_file_entry(entries, &remainder, digest);
249 }
250 DirOrFileEntry::FileEntry(_) => {
251 let mut entries = HashMap::new();
252 add_dir_or_file_entry(&mut entries, &remainder, digest);
253 entry.insert(DirOrFileEntry::DirEntry(entries));
254 }
255 },
256 Entry::Vacant(entry) => {
257 let mut entries = HashMap::new();
258 add_dir_or_file_entry(&mut entries, &remainder, digest);
259 entry.insert(DirOrFileEntry::DirEntry(entries));
260 }
261 }
262 }
263 }
264 _ => {
265 dir_map.insert(
266 relative_path.to_path_buf(),
267 DirOrFileEntry::FileEntry(digest),
268 );
269 }
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn contains_dylib_detects_treesitter_parser() {
279 let temp = fs::tempfile::tempdir().unwrap();
280 let parser_dir = temp.path().join("parser");
281 fs::sync::create_dir_all(parser_dir.join("nested")).unwrap();
282 fs::sync::write(
283 parser_dir.join(format!("tmux.{}", std::env::consts::DLL_EXTENSION)),
284 b"not a real dylib",
285 )
286 .unwrap();
287
288 assert!(contains_dylib(&parser_dir));
289 assert!(!contains_dylib(&parser_dir.join("nested")));
290 assert!(!contains_dylib(&parser_dir.join("absent")));
291 }
292
293 #[test]
294 fn contains_dylib_ignores_query_files() {
295 let temp = fs::tempfile::tempdir().unwrap();
296 let parser_dir = temp.path().join("parser");
297 fs::sync::create_dir_all(&parser_dir).unwrap();
298 fs::sync::write(parser_dir.join("highlights.scm"), b"; comment").unwrap();
299 fs::sync::write(parser_dir.join("README.md"), b"# tree-sitter").unwrap();
300
301 assert!(!contains_dylib(&parser_dir));
302 }
303
304 #[test]
306 fn add_dir_or_file_entry_preserves_siblings_and_nested_entries() {
307 let mut entries = HashMap::new();
308
309 add_dir_or_file_entry(&mut entries, Path::new("docs/a.txt"), "digest-a".into());
310 add_dir_or_file_entry(&mut entries, Path::new("docs/b.txt"), "digest-b".into());
311 add_dir_or_file_entry(
312 &mut entries,
313 Path::new("docs/nested/c.txt"),
314 "digest-c".into(),
315 );
316
317 assert_eq!(
318 entries,
319 HashMap::from([(
320 PathBuf::from("docs"),
321 DirOrFileEntry::DirEntry(HashMap::from([
322 (
323 PathBuf::from("a.txt"),
324 DirOrFileEntry::FileEntry("digest-a".into())
325 ),
326 (
327 PathBuf::from("b.txt"),
328 DirOrFileEntry::FileEntry("digest-b".into())
329 ),
330 (
331 PathBuf::from("nested"),
332 DirOrFileEntry::DirEntry(HashMap::from([(
333 PathBuf::from("c.txt"),
334 DirOrFileEntry::FileEntry("digest-c".into()),
335 )])),
336 ),
337 ])),
338 )])
339 );
340 }
341
342 #[cfg(unix)]
344 #[test]
345 fn add_rock_entries_with_relative_source_dir() -> Result<(), PackError> {
346 let temp = fs::tempfile::tempdir()?;
347 let abs_source_dir = temp.path().join("src");
348 fs::sync::create_dir_all(abs_source_dir.join("sub"))?;
349 fs::sync::write(abs_source_dir.join("plainp.lua"), b"-- plainp")?;
350 fs::sync::write(abs_source_dir.join("sub").join("mod.lua"), b"-- mod")?;
351
352 let relative_source_dir = pathdiff::diff_paths(&abs_source_dir, std::env::current_dir()?)
354 .unwrap_or_else(|| unreachable!("temp dir and cwd share a root on unix"));
355 assert!(relative_source_dir.is_dir());
356
357 let expected_entries = HashMap::from([
358 (
359 PathBuf::from("plainp.lua"),
360 DirOrFileEntry::FileEntry(format!("{:x}", md5::compute(b"-- plainp"))),
361 ),
362 (
363 PathBuf::from("sub"),
364 DirOrFileEntry::DirEntry(HashMap::from([(
365 PathBuf::from("mod.lua"),
366 DirOrFileEntry::FileEntry(format!("{:x}", md5::compute(b"-- mod"))),
367 )])),
368 ),
369 ]);
370
371 let source_dirs = [&relative_source_dir, &abs_source_dir];
373 for (index, source_dir) in source_dirs.into_iter().enumerate() {
374 let zip_path = temp.path().join(format!("out-{}.rock", index));
375 let mut zip = ZipWriter::new(fs::sync::create(&zip_path)?);
376 let entries = add_rock_entries(&mut zip, source_dir, "lua".into())?;
377 zip.finish()?;
378
379 let mut archive = zip::ZipArchive::new(fs::sync::open(&zip_path)?)?;
380 assert_eq!(archive.len(), 2);
381 assert!(archive.by_name("lua/plainp.lua").is_ok());
382 let mut contents = Vec::new();
383 let mut mod_file = archive.by_name("lua/sub/mod.lua")?;
384 mod_file.read_to_end(&mut contents)?;
385 assert_eq!(contents, b"-- mod");
386
387 assert_eq!(entries, expected_entries);
388 }
389
390 Ok(())
391 }
392}