Skip to main content

stout_install/
bottle.rs

1//! Bottle creation from installed packages
2//!
3//! This module provides functionality to create Homebrew-compatible bottle
4//! archives from packages that have been installed (either from source or
5//! from existing bottles).
6
7use crate::error::{Error, Result};
8use flate2::write::GzEncoder;
9use flate2::Compression;
10use sha2::{Digest, Sha256};
11use std::fs::File;
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use tar::Builder;
15use tracing::{debug, info};
16
17/// Result of bottle creation
18#[derive(Debug)]
19pub struct BottleResult {
20    /// Path to the created bottle
21    pub path: PathBuf,
22    /// Size in bytes
23    pub size: u64,
24    /// SHA256 hash of the bottle
25    pub sha256: String,
26    /// Number of files included
27    pub file_count: usize,
28}
29
30/// Create a bottle from an installed package
31///
32/// # Arguments
33///
34/// * `install_path` - Path to the installed package (e.g., /opt/homebrew/Cellar/jq/1.7.1)
35/// * `output_path` - Path where the bottle should be written
36/// * `name` - Package name
37/// * `version` - Package version
38///
39/// # Returns
40///
41/// A `BottleResult` containing information about the created bottle
42pub fn create_bottle(
43    install_path: &Path,
44    output_path: &Path,
45    name: &str,
46    version: &str,
47) -> Result<BottleResult> {
48    info!(
49        "Creating bottle for {} {} from {:?}",
50        name, version, install_path
51    );
52
53    if !install_path.exists() {
54        return Err(Error::Bottle(format!(
55            "Install path does not exist: {:?}",
56            install_path
57        )));
58    }
59
60    // Create the tar.gz archive
61    let output_file = File::create(output_path)
62        .map_err(|e| Error::Bottle(format!("Failed to create output file: {}", e)))?;
63
64    let encoder = GzEncoder::new(output_file, Compression::default());
65    let mut builder = Builder::new(encoder);
66
67    // The bottle should contain the package in the format:
68    // <name>/<version>/<contents>
69    let base_path = format!("{}/{}", name, version);
70    let mut file_count = 0;
71
72    // Recursively add all files
73    file_count += add_directory_to_tar(&mut builder, install_path, &base_path)?;
74
75    // Finish the archive
76    let encoder = builder
77        .into_inner()
78        .map_err(|e| Error::Bottle(format!("Failed to finish archive: {}", e)))?;
79
80    encoder
81        .finish()
82        .map_err(|e| Error::Bottle(format!("Failed to finish gzip: {}", e)))?;
83
84    // Calculate SHA256
85    let file_bytes = std::fs::read(output_path)
86        .map_err(|e| Error::Bottle(format!("Failed to read bottle: {}", e)))?;
87
88    let mut hasher = Sha256::new();
89    hasher.update(&file_bytes);
90    let sha256 = format!("{:x}", hasher.finalize());
91
92    let size = file_bytes.len() as u64;
93
94    info!("Created bottle: {} bytes, {} files", size, file_count);
95
96    Ok(BottleResult {
97        path: output_path.to_path_buf(),
98        size,
99        sha256,
100        file_count,
101    })
102}
103
104/// Add a directory and its contents to a tar archive
105fn add_directory_to_tar<W: Write>(
106    builder: &mut Builder<W>,
107    dir_path: &Path,
108    archive_base: &str,
109) -> Result<usize> {
110    let mut count = 0;
111
112    for entry in std::fs::read_dir(dir_path)
113        .map_err(|e| Error::Bottle(format!("Failed to read directory: {}", e)))?
114    {
115        let entry =
116            entry.map_err(|e| Error::Bottle(format!("Failed to read directory entry: {}", e)))?;
117        let path = entry.path();
118        let file_name = entry.file_name();
119        let archive_path = format!("{}/{}", archive_base, file_name.to_string_lossy());
120
121        if path.is_dir() {
122            // Recursively add directory
123            count += add_directory_to_tar(builder, &path, &archive_path)?;
124        } else if path.is_symlink() {
125            // Handle symlinks
126            let link_target = std::fs::read_link(&path)
127                .map_err(|e| Error::Bottle(format!("Failed to read symlink: {}", e)))?;
128
129            let mut header = tar::Header::new_gnu();
130            header.set_entry_type(tar::EntryType::Symlink);
131            header.set_size(0);
132
133            // Get file metadata for permissions
134            if let Ok(metadata) = std::fs::symlink_metadata(&path) {
135                header.set_mode(get_mode(&metadata));
136                header.set_mtime(
137                    metadata
138                        .modified()
139                        .ok()
140                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
141                        .map(|d| d.as_secs())
142                        .unwrap_or(0),
143                );
144            }
145
146            builder
147                .append_link(&mut header, &archive_path, &link_target)
148                .map_err(|e| Error::Bottle(format!("Failed to add symlink to archive: {}", e)))?;
149
150            debug!("Added symlink: {} -> {:?}", archive_path, link_target);
151            count += 1;
152        } else if path.is_file() {
153            // Regular file
154            let metadata = std::fs::metadata(&path)
155                .map_err(|e| Error::Bottle(format!("Failed to get file metadata: {}", e)))?;
156
157            let mut header = tar::Header::new_gnu();
158            header.set_size(metadata.len());
159            header.set_mode(get_mode(&metadata));
160            header.set_mtime(
161                metadata
162                    .modified()
163                    .ok()
164                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
165                    .map(|d| d.as_secs())
166                    .unwrap_or(0),
167            );
168
169            let mut file = File::open(&path)
170                .map_err(|e| Error::Bottle(format!("Failed to open file: {}", e)))?;
171
172            builder
173                .append_data(&mut header, &archive_path, &mut file)
174                .map_err(|e| Error::Bottle(format!("Failed to add file to archive: {}", e)))?;
175
176            debug!("Added file: {}", archive_path);
177            count += 1;
178        }
179    }
180
181    Ok(count)
182}
183
184/// Get the file mode from metadata
185fn get_mode(metadata: &std::fs::Metadata) -> u32 {
186    #[cfg(unix)]
187    {
188        use std::os::unix::fs::PermissionsExt;
189        metadata.permissions().mode()
190    }
191    #[cfg(not(unix))]
192    {
193        if metadata.is_dir() {
194            0o755
195        } else {
196            0o644
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use std::fs;
205    use tempfile::TempDir;
206
207    #[test]
208    fn test_create_bottle() {
209        let temp_dir = TempDir::new().unwrap();
210        let install_path = temp_dir.path().join("test-pkg/1.0.0");
211        let bin_dir = install_path.join("bin");
212        fs::create_dir_all(&bin_dir).unwrap();
213
214        // Create a test binary
215        fs::write(bin_dir.join("test-binary"), b"#!/bin/sh\necho hello").unwrap();
216
217        let bottle_path = temp_dir.path().join("test-pkg-1.0.0.bottle.tar.gz");
218
219        let result = create_bottle(&install_path, &bottle_path, "test-pkg", "1.0.0").unwrap();
220
221        assert!(result.path.exists());
222        assert!(result.size > 0);
223        assert!(!result.sha256.is_empty());
224        assert!(result.file_count > 0);
225    }
226}