1use crate::build::utils;
2use crate::build::utils::c_dylib_extension;
3use crate::fs;
4use crate::lockfile::LockedPackage;
5use crate::luarocks;
6use crate::luarocks::rock_manifest::DirOrFileEntry;
7use crate::luarocks::rock_manifest::RockManifest;
8use crate::luarocks::rock_manifest::RockManifestBin;
9use crate::luarocks::rock_manifest::RockManifestConf;
10use crate::luarocks::rock_manifest::RockManifestDoc;
11use crate::luarocks::rock_manifest::RockManifestLib;
12use crate::luarocks::rock_manifest::RockManifestLua;
13use crate::luarocks::rock_manifest::RockManifestRoot;
14use crate::tree::InstallTree;
15use crate::tree::Tree;
16use crate::tree::TreeError;
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::FsError),
67 Walkdir(#[from] walkdir::Error),
68 #[error(transparent)]
69 #[diagnostic(transparent)]
70 Tree(#[from] TreeError),
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 is_binary_rock(&layout.lib) {
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 utils::recursive_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(), &PathBuf::default())?;
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 is_binary_rock(lib_dir: &Path) -> bool {
161 if !lib_dir.is_dir() {
162 return false;
163 }
164 WalkDir::new(lib_dir).into_iter().any(|entry| {
165 entry.is_ok_and(|entry| {
166 let file = entry.into_path();
167 file.is_file()
168 && file
169 .extension()
170 .is_some_and(|ext| ext.to_string_lossy() == c_dylib_extension())
171 })
172 })
173}
174
175fn add_rock_entries(
176 zip: &mut ZipWriter<File>,
177 source_dir: &Path,
178 zip_dir: PathBuf,
179) -> Result<HashMap<PathBuf, DirOrFileEntry>, PackError> {
180 let mut result = HashMap::new();
181 if source_dir.is_dir() {
182 for file in WalkDir::new(source_dir).into_iter().filter_map_ok(|entry| {
183 let file = entry.into_path();
184 if file.is_file() {
185 Some(file)
186 } else {
187 None
188 }
189 }) {
190 let file = file?;
191 let (relative_path, digest) = add_rock_entry(zip, file, source_dir, &zip_dir)?;
192 add_dir_or_file_entry(&mut result, &relative_path, digest);
193 }
194 }
195 Ok(result)
196}
197
198fn add_rock_entry(
199 zip: &mut ZipWriter<File>,
200 file: PathBuf,
201 source_dir: &Path,
202 zip_dir: &Path,
203) -> Result<(PathBuf, String), PackError> {
204 let relative_path: PathBuf = file
205 .strip_prefix(source_dir)
206 .unwrap_or(file.as_path())
207 .to_path_buf();
208 let mut f = fs::sync::open(file)?;
209 let mut buffer = Vec::new();
210 f.read_to_end(&mut buffer)?;
211 let digest = md5::compute(&buffer);
212
213 #[cfg(target_family = "unix")]
214 let options = SimpleFileOptions::default()
215 .compression_method(zip::CompressionMethod::Stored)
216 .unix_permissions(f.metadata()?.permissions().mode());
217 #[cfg(target_family = "windows")]
218 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
219
220 zip.start_file(zip_dir.join(&relative_path).to_string_lossy(), options)?;
221 zip.write_all(&buffer)?;
222 Ok((relative_path, format!("{digest:x}")))
223}
224
225fn add_dir_or_file_entry(
226 dir_map: &mut HashMap<PathBuf, DirOrFileEntry>,
227 relative_path: &Path,
228 digest: String,
229) {
230 let mut components = relative_path
231 .components()
232 .filter_map(|component| match component {
233 std::path::Component::Normal(path) => Some(PathBuf::from(path)),
234 _ => None,
235 })
236 .collect::<VecDeque<_>>();
237 match &components.len() {
238 n if *n > 1 => {
239 if let Some(first_dir) = components.pop_front() {
240 let remainder = components.iter().collect::<PathBuf>();
241 match dir_map.entry(first_dir) {
242 Entry::Occupied(mut entry) => match entry.get_mut() {
243 DirOrFileEntry::DirEntry(entries) => {
244 add_dir_or_file_entry(entries, &remainder, digest);
245 }
246 DirOrFileEntry::FileEntry(_) => {
247 let mut entries = HashMap::new();
248 add_dir_or_file_entry(&mut entries, &remainder, digest);
249 entry.insert(DirOrFileEntry::DirEntry(entries));
250 }
251 },
252 Entry::Vacant(entry) => {
253 let mut entries = HashMap::new();
254 add_dir_or_file_entry(&mut entries, &remainder, digest);
255 entry.insert(DirOrFileEntry::DirEntry(entries));
256 }
257 }
258 }
259 }
260 _ => {
261 dir_map.insert(
262 relative_path.to_path_buf(),
263 DirOrFileEntry::FileEntry(digest),
264 );
265 }
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
275 fn add_dir_or_file_entry_preserves_siblings_and_nested_entries() {
276 let mut entries = HashMap::new();
277
278 add_dir_or_file_entry(&mut entries, Path::new("docs/a.txt"), "digest-a".into());
279 add_dir_or_file_entry(&mut entries, Path::new("docs/b.txt"), "digest-b".into());
280 add_dir_or_file_entry(
281 &mut entries,
282 Path::new("docs/nested/c.txt"),
283 "digest-c".into(),
284 );
285
286 assert_eq!(
287 entries,
288 HashMap::from([(
289 PathBuf::from("docs"),
290 DirOrFileEntry::DirEntry(HashMap::from([
291 (
292 PathBuf::from("a.txt"),
293 DirOrFileEntry::FileEntry("digest-a".into())
294 ),
295 (
296 PathBuf::from("b.txt"),
297 DirOrFileEntry::FileEntry("digest-b".into())
298 ),
299 (
300 PathBuf::from("nested"),
301 DirOrFileEntry::DirEntry(HashMap::from([(
302 PathBuf::from("c.txt"),
303 DirOrFileEntry::FileEntry("digest-c".into()),
304 )])),
305 ),
306 ])),
307 )])
308 );
309 }
310
311 #[cfg(unix)]
313 #[test]
314 fn add_rock_entries_with_relative_source_dir() -> Result<(), PackError> {
315 let temp = fs::tempfile::tempdir()?;
316 let abs_source_dir = temp.path().join("src");
317 fs::sync::create_dir_all(abs_source_dir.join("sub"))?;
318 fs::sync::write(abs_source_dir.join("plainp.lua"), b"-- plainp")?;
319 fs::sync::write(abs_source_dir.join("sub").join("mod.lua"), b"-- mod")?;
320
321 let relative_source_dir = pathdiff::diff_paths(&abs_source_dir, std::env::current_dir()?)
323 .unwrap_or_else(|| unreachable!("temp dir and cwd share a root on unix"));
324 assert!(relative_source_dir.is_dir());
325
326 let expected_entries = HashMap::from([
327 (
328 PathBuf::from("plainp.lua"),
329 DirOrFileEntry::FileEntry(format!("{:x}", md5::compute(b"-- plainp"))),
330 ),
331 (
332 PathBuf::from("sub"),
333 DirOrFileEntry::DirEntry(HashMap::from([(
334 PathBuf::from("mod.lua"),
335 DirOrFileEntry::FileEntry(format!("{:x}", md5::compute(b"-- mod"))),
336 )])),
337 ),
338 ]);
339
340 let source_dirs = [&relative_source_dir, &abs_source_dir];
342 for (index, source_dir) in source_dirs.into_iter().enumerate() {
343 let zip_path = temp.path().join(format!("out-{}.rock", index));
344 let mut zip = ZipWriter::new(fs::sync::create(&zip_path)?);
345 let entries = add_rock_entries(&mut zip, source_dir, "lua".into())?;
346 zip.finish()?;
347
348 let mut archive = zip::ZipArchive::new(fs::sync::open(&zip_path)?)?;
349 assert_eq!(archive.len(), 2);
350 assert!(archive.by_name("lua/plainp.lua").is_ok());
351 let mut contents = Vec::new();
352 let mut mod_file = archive.by_name("lua/sub/mod.lua")?;
353 mod_file.read_to_end(&mut contents)?;
354 assert_eq!(contents, b"-- mod");
355
356 assert_eq!(entries, expected_entries);
357 }
358
359 Ok(())
360 }
361}