Skip to main content

lux_lib/luarocks/
install_binary_rock.rs

1use std::{
2    collections::HashMap,
3    io::{self, Cursor},
4    path::{Path, PathBuf},
5};
6
7use crate::{
8    build::{
9        external_dependency::{ExternalDependencyError, ExternalDependencyInfo},
10        utils::recursive_copy_dir,
11        BuildBehaviour,
12    },
13    config::Config,
14    hash::HasIntegrity,
15    lockfile::{
16        LocalPackage, LocalPackageHashes, LockConstraint, LockfileError, OptState, PinnedState,
17    },
18    lua_rockspec::{LuaVersionError, RemoteLuaRockspec},
19    luarocks::rock_manifest::RockManifest,
20    package::PackageSpec,
21    remote_package_source::RemotePackageSource,
22    rockspec::Rockspec,
23    tree::{self, InstallTree, TreeError},
24};
25use crate::{lockfile::RemotePackageSourceUrl, rockspec::LuaVersionCompatibility};
26use bytes::Bytes;
27use miette::Diagnostic;
28use tempfile::tempdir;
29use thiserror::Error;
30
31use super::rock_manifest::RockManifestError;
32
33#[derive(Error, Debug, Diagnostic)]
34#[non_exhaustive]
35pub enum InstallBinaryRockError {
36    #[error("IO operation failed: {0}")]
37    Io(#[from] io::Error),
38    #[error(transparent)]
39    #[diagnostic(transparent)]
40    Lockfile(#[from] LockfileError),
41    #[error(transparent)]
42    #[diagnostic(transparent)]
43    Tree(#[from] TreeError),
44    #[error(transparent)]
45    #[diagnostic(transparent)]
46    ExternalDependencyError(#[from] ExternalDependencyError),
47    #[error(transparent)]
48    #[diagnostic(transparent)]
49    LuaVersionError(#[from] LuaVersionError),
50    #[error("failed to unpack packed rock: {0}")]
51    Zip(#[from] zip::result::ZipError),
52    #[error("rock_manifest not found. Cannot install rock files that were packed using LuaRocks version 1")]
53    RockManifestNotFound,
54    #[error(transparent)]
55    #[diagnostic(transparent)]
56    RockManifestError(#[from] RockManifestError),
57    #[error(
58        "the entry {0} listed in the `rock_manifest` is neither a file nor a directory: {1:?}"
59    )]
60    NotAFileOrDirectory(String, std::fs::Metadata),
61}
62
63pub(crate) struct BinaryRockInstall<'a, T>
64where
65    T: InstallTree,
66{
67    rockspec: &'a RemoteLuaRockspec,
68    rock_bytes: Bytes,
69    source: RemotePackageSource,
70    pin: PinnedState,
71    opt: OptState,
72    entry_type: tree::EntryType,
73    constraint: LockConstraint,
74    behaviour: BuildBehaviour,
75    config: &'a Config,
76    tree: &'a T,
77}
78
79impl<'a, T> BinaryRockInstall<'a, T>
80where
81    T: InstallTree,
82{
83    pub(crate) fn new(
84        rockspec: &'a RemoteLuaRockspec,
85        source: RemotePackageSource,
86        rock_bytes: Bytes,
87        entry_type: tree::EntryType,
88        config: &'a Config,
89        tree: &'a T,
90    ) -> Self {
91        Self {
92            rockspec,
93            rock_bytes,
94            source,
95            config,
96            tree,
97            constraint: LockConstraint::default(),
98            behaviour: BuildBehaviour::default(),
99            pin: PinnedState::default(),
100            opt: OptState::default(),
101            entry_type,
102        }
103    }
104
105    pub(crate) fn pin(self, pin: PinnedState) -> Self {
106        Self { pin, ..self }
107    }
108
109    pub(crate) fn opt(self, opt: OptState) -> Self {
110        Self { opt, ..self }
111    }
112
113    pub(crate) fn constraint(self, constraint: LockConstraint) -> Self {
114        Self { constraint, ..self }
115    }
116
117    pub(crate) fn behaviour(self, behaviour: BuildBehaviour) -> Self {
118        Self { behaviour, ..self }
119    }
120
121    #[tracing::instrument(name = "Installing binary rock", skip_all)]
122    pub(crate) async fn install(self) -> Result<LocalPackage, InstallBinaryRockError> {
123        let rockspec = self.rockspec;
124        for (name, dep) in rockspec.external_dependencies().current_platform() {
125            let _ = ExternalDependencyInfo::probe(name, dep, self.config.external_deps())?;
126        }
127
128        rockspec.validate_lua_version_from_config(self.config)?;
129
130        let hashes = LocalPackageHashes {
131            rockspec: rockspec.hash()?,
132            source: self.rock_bytes.hash()?,
133        };
134        let source_url = match &self.source {
135            RemotePackageSource::LuarocksBinaryRock(url) => {
136                Some(RemotePackageSourceUrl::Url { url: url.clone() })
137            }
138            _ => None,
139        };
140        let mut package = LocalPackage::from(
141            &PackageSpec::new(rockspec.package().clone(), rockspec.version().clone()),
142            self.constraint,
143            rockspec.binaries(),
144            self.source,
145            source_url,
146            hashes,
147        );
148        package.spec.pinned = self.pin;
149        package.spec.opt = self.opt;
150        match self.tree.lockfile()?.get(&package.id()) {
151            Some(package) if self.behaviour == BuildBehaviour::NoForce => Ok(package.clone()),
152            _ => {
153                let unpack_dir = tempdir()?;
154                let cursor = Cursor::new(self.rock_bytes);
155                let mut zip = zip::ZipArchive::new(cursor)?;
156                zip.extract(&unpack_dir)?;
157                // let lua_dir = unpack_dir.join("lua");
158                // if lua_dir.is_dir() {
159                //     let src_dir = unpack_dir.join("lua");
160                //     tokio::fs::rename(lua_dir, src_dir).await?;
161                // }
162                let rock_manifest_file = unpack_dir.path().join("rock_manifest");
163                if !rock_manifest_file.is_file() {
164                    return Err(InstallBinaryRockError::RockManifestNotFound);
165                }
166                let rock_manifest_content = tokio::fs::read_to_string(rock_manifest_file).await?;
167                let output_paths = match self.entry_type {
168                    tree::EntryType::Entrypoint => self.tree.entrypoint(&package)?,
169                    tree::EntryType::DependencyOnly => self.tree.dependency(&package)?,
170                };
171                let rock_manifest = RockManifest::new(&rock_manifest_content)?;
172                install_manifest_entries(
173                    &rock_manifest.lib.entries,
174                    &unpack_dir.path().join("lib"),
175                    &output_paths.lib,
176                )
177                .await?;
178                install_manifest_entries(
179                    &rock_manifest.lua.entries,
180                    &unpack_dir.path().join("lua"),
181                    &output_paths.src,
182                )
183                .await?;
184                install_manifest_entries(
185                    &rock_manifest.bin.entries,
186                    &unpack_dir.path().join("bin"),
187                    &output_paths.bin,
188                )
189                .await?;
190                install_manifest_entries(
191                    &rock_manifest.doc.entries,
192                    &unpack_dir.path().join("doc"),
193                    &output_paths.doc,
194                )
195                .await?;
196                install_manifest_entries(
197                    &rock_manifest.root.entries,
198                    unpack_dir.path(),
199                    &output_paths.etc,
200                )
201                .await?;
202                // rename <name>-<version>.rockspec
203                let rockspec_path = output_paths.etc.join(format!(
204                    "{}-{}.rockspec",
205                    package.name(),
206                    package.version()
207                ));
208                if rockspec_path.is_file() {
209                    tokio::fs::copy(&rockspec_path, output_paths.rockspec_path()).await?;
210                    tokio::fs::remove_file(&rockspec_path).await?;
211                }
212                Ok(package)
213            }
214        }
215    }
216}
217
218#[tracing::instrument(level = "trace", skip(entry))]
219async fn install_manifest_entries<T>(
220    entry: &HashMap<PathBuf, T>,
221    src: &Path,
222    dest: &Path,
223) -> Result<(), InstallBinaryRockError> {
224    for relative_src_path in entry.keys() {
225        let target = dest.join(relative_src_path);
226        let src_path = src.join(relative_src_path);
227        if src_path.is_dir() {
228            recursive_copy_dir(&src_path, &target).await?;
229        } else if src_path.is_file() {
230            if let Some(target_parent_dir) = target.parent() {
231                tokio::fs::create_dir_all(target_parent_dir).await?;
232            }
233            tokio::fs::copy(src.join(relative_src_path), target).await?;
234        } else {
235            let metadata = tokio::fs::metadata(&src_path).await?;
236            return Err(InstallBinaryRockError::NotAFileOrDirectory(
237                src_path.to_string_lossy().to_string(),
238                metadata,
239            ));
240        }
241    }
242    Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247
248    use io::Read;
249
250    use crate::{
251        config::ConfigBuilder,
252        operations::{unpack_rockspec, DownloadedPackedRockBytes},
253    };
254
255    use super::*;
256
257    #[tokio::test]
258    async fn install_binary_rock() {
259        let rock = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
260            .join("resources/test/sample-project-0.1.0-1.all.rock");
261        let content = std::fs::read(rock).unwrap();
262        let rock_bytes = Bytes::copy_from_slice(&content);
263        let packed_rock_file_name = "sample-project-0.1.0-1.all.rock".to_string();
264        let cursor = Cursor::new(rock_bytes.clone());
265        let mut zip = zip::ZipArchive::new(cursor).unwrap();
266        let manifest_index = zip.index_for_path("rock_manifest").unwrap();
267        let mut manifest_file = zip.by_index(manifest_index).unwrap();
268        let mut content = String::new();
269        manifest_file.read_to_string(&mut content).unwrap();
270        let rock = DownloadedPackedRockBytes {
271            name: "sample-project".into(),
272            version: "0.1.0-1".parse().unwrap(),
273            bytes: rock_bytes,
274            file_name: packed_rock_file_name.clone(),
275            url: "https://test.org".parse().unwrap(),
276        };
277        let rockspec = unpack_rockspec(&rock).await.unwrap();
278        let install_root = assert_fs::TempDir::new().unwrap();
279        let config = ConfigBuilder::new()
280            .unwrap()
281            .user_tree(Some(install_root.to_path_buf()))
282            .build()
283            .unwrap();
284        let tree = config
285            .user_tree(config.lua_version().unwrap().clone())
286            .unwrap();
287        let local_package = BinaryRockInstall::new(
288            &rockspec,
289            RemotePackageSource::Test,
290            rock.bytes,
291            tree::EntryType::Entrypoint,
292            &config,
293            &tree,
294        )
295        .install()
296        .await
297        .unwrap();
298        let rock_layout = tree.entrypoint_layout(&local_package);
299        let foo_bar_module = rock_layout.src.join("foo").join("bar.lua");
300        assert!(foo_bar_module.is_file());
301    }
302
303    /// This relatively large integration test case tests the following:
304    ///
305    /// - Install a packed rock that was packed using luarocks 3.11 from the test resources.
306    /// - Pack the rock using our own `Pack` implementation.
307    /// - Verify that the `rock_manifest` entry of the original packed rock and our own packed rock
308    ///   are equal (this means luarocks should be able to install our packed rock).
309    /// - Uninstall the local package.
310    /// - Install the package from our packed rock.
311    /// - Verify that the contents of the install directories when installing from both packed rocks
312    ///   are the same.
313    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
314    #[tokio::test]
315    async fn install_binary_rock_roundtrip() {
316        use crate::operations::{Pack, Uninstall};
317
318        if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
319            println!("Skipping impure test");
320            return;
321        }
322        let rock = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
323            .join("resources/test/toml-edit-0.6.0-1.linux-x86_64.rock");
324        let content = std::fs::read(rock).unwrap();
325        let rock_bytes = Bytes::copy_from_slice(&content);
326        let packed_rock_file_name = "toml-edit-0.6.0-1.linux-x86_64.rock".to_string();
327        let cursor = Cursor::new(rock_bytes.clone());
328        let mut zip = zip::ZipArchive::new(cursor).unwrap();
329        let manifest_index = zip.index_for_path("rock_manifest").unwrap();
330        let mut manifest_file = zip.by_index(manifest_index).unwrap();
331        let mut content = String::new();
332        manifest_file.read_to_string(&mut content).unwrap();
333        let orig_manifest = RockManifest::new(&content).unwrap();
334        let rock = DownloadedPackedRockBytes {
335            name: "toml-edit".into(),
336            version: "0.6.0-1".parse().unwrap(),
337            bytes: rock_bytes,
338            file_name: packed_rock_file_name.clone(),
339            url: "https://test.org".parse().unwrap(),
340        };
341        let rockspec = unpack_rockspec(&rock).await.unwrap();
342        let install_root = assert_fs::TempDir::new().unwrap();
343        let config = ConfigBuilder::new()
344            .unwrap()
345            .user_tree(Some(install_root.to_path_buf()))
346            .build()
347            .unwrap();
348        let tree = config
349            .user_tree(config.lua_version().unwrap().clone())
350            .unwrap();
351        let local_package = BinaryRockInstall::new(
352            &rockspec,
353            RemotePackageSource::Test,
354            rock.bytes,
355            tree::EntryType::Entrypoint,
356            &config,
357            &tree,
358        )
359        .install()
360        .await
361        .unwrap();
362        let rock_layout = tree.entrypoint_layout(&local_package);
363
364        assert!(rock_layout.lib.join("toml_edit.so").is_file());
365
366        let orig_install_tree_integrity = rock_layout.rock_path.hash().unwrap();
367
368        let pack_dest_dir = assert_fs::TempDir::new().unwrap();
369        let packed_rock = Pack::new(
370            pack_dest_dir.to_path_buf(),
371            tree.clone(),
372            local_package.clone(),
373        )
374        .pack()
375        .await
376        .unwrap();
377        assert_eq!(
378            packed_rock
379                .file_name()
380                .unwrap()
381                .to_string_lossy()
382                .to_string(),
383            packed_rock_file_name.clone()
384        );
385
386        // let's make sure our own pack/unpack implementation roundtrips correctly
387        Uninstall::new()
388            .config(&config)
389            .package(local_package.id())
390            .remove()
391            .await
392            .unwrap();
393        let content = std::fs::read(&packed_rock).unwrap();
394        let rock_bytes = Bytes::copy_from_slice(&content);
395        let cursor = Cursor::new(rock_bytes.clone());
396        let mut zip = zip::ZipArchive::new(cursor).unwrap();
397        let manifest_index = zip.index_for_path("rock_manifest").unwrap();
398        let mut manifest_file = zip.by_index(manifest_index).unwrap();
399        let mut content = String::new();
400        manifest_file.read_to_string(&mut content).unwrap();
401        let packed_manifest = RockManifest::new(&content).unwrap();
402        assert_eq!(packed_manifest, orig_manifest);
403        let rock = DownloadedPackedRockBytes {
404            name: "toml-edit".into(),
405            version: "0.6.0-1".parse().unwrap(),
406            bytes: rock_bytes,
407            file_name: packed_rock_file_name.clone(),
408            url: "https://test.org".parse().unwrap(),
409        };
410        let rockspec = unpack_rockspec(&rock).await.unwrap();
411        let local_package = BinaryRockInstall::new(
412            &rockspec,
413            RemotePackageSource::Test,
414            rock.bytes,
415            tree::EntryType::Entrypoint,
416            &config,
417            &tree,
418        )
419        .install()
420        .await
421        .unwrap();
422        let rock_layout = tree.entrypoint_layout(&local_package);
423        assert!(rock_layout.rockspec_path().is_file());
424        let new_install_tree_integrity = rock_layout.rock_path.hash().unwrap();
425        assert_eq!(orig_install_tree_integrity, new_install_tree_integrity);
426    }
427}