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