Skip to main content

lux_lib/operations/
pack.rs

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/// A binary rock packer
39#[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    // We copy entries from `etc` to the root directory, as luarocks doesn't have an etc directory.
96    let temp_root_dir = fs::tempfile::tempdir()?;
97    project::copy_dir(&layout.etc, temp_root_dir.path()).await?;
98    // prevent duplicate doc and conf entries
99    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    // luarocks expects a <package>-<version>.rockspec,
108    // so we copy it the package.rockspec to our temporary root directory and rename it
109    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    // Tree-sitter parsers are built with `DLL_EXTENSION`, which is `dylib` on macOS
165    // whereas Lua names its C libraries `so`.
166    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    // non-regression for https://github.com/lumen-oss/lux/issues/2031
305    #[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    // non-regression for https://github.com/lumen-oss/lux/issues/2116
343    #[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        // Express the source dir relative to the cwd, as `--tree ./tree` does.
353        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        // The relative case is the regression, the absolute one the mainline behaviour.
372        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}