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