Skip to main content

stout_install/
build.rs

1//! Build from source support
2//!
3//! This module provides functionality to build formulas from source
4//! when pre-built bottles are not available for the current platform.
5
6use crate::error::{BuildError, Error, Result};
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use tracing::{debug, info, warn};
10
11/// Build configuration
12#[derive(Debug, Clone)]
13pub struct BuildConfig {
14    /// Source archive URL
15    pub source_url: String,
16    /// Expected SHA256 hash
17    pub sha256: String,
18    /// Formula name
19    pub name: String,
20    /// Version
21    pub version: String,
22    /// Homebrew prefix (e.g., /opt/homebrew)
23    pub prefix: PathBuf,
24    /// Cellar path (e.g., /opt/homebrew/Cellar)
25    pub cellar: PathBuf,
26    /// Build dependencies to ensure are installed
27    pub build_deps: Vec<String>,
28    /// Number of parallel build jobs (default: auto-detect)
29    pub jobs: Option<usize>,
30    /// C compiler to use
31    pub cc: Option<String>,
32    /// C++ compiler to use
33    pub cxx: Option<String>,
34}
35
36impl BuildConfig {
37    /// Get the number of parallel jobs to use
38    pub fn get_jobs(&self) -> usize {
39        self.jobs.unwrap_or_else(num_cpus::get)
40    }
41}
42
43/// Build result
44#[derive(Debug)]
45pub struct BuildResult {
46    /// Path to the installed package
47    pub install_path: PathBuf,
48}
49
50/// Source builder for formulas
51pub struct SourceBuilder {
52    config: BuildConfig,
53    work_dir: PathBuf,
54}
55
56impl SourceBuilder {
57    /// Create a new source builder
58    pub fn new(config: BuildConfig, work_dir: impl AsRef<Path>) -> Self {
59        Self {
60            config,
61            work_dir: work_dir.as_ref().to_path_buf(),
62        }
63    }
64
65    /// Build the formula from source
66    pub async fn build(&self) -> Result<BuildResult> {
67        info!("Building {} {} from source", self.config.name, self.config.version);
68
69        // Create work directory
70        std::fs::create_dir_all(&self.work_dir)?;
71
72        // Download source
73        let archive_path = self.download_source().await?;
74
75        // Extract source
76        let source_dir = self.extract_source(&archive_path)?;
77
78        // Build
79        let install_path = self.run_build(&source_dir)?;
80
81        Ok(BuildResult { install_path })
82    }
83
84    /// Download the source archive
85    async fn download_source(&self) -> Result<PathBuf> {
86        use sha2::{Digest, Sha256};
87
88        let archive_name = self.config.source_url
89            .rsplit('/')
90            .next()
91            .unwrap_or("source.tar.gz");
92        let archive_path = self.work_dir.join(archive_name);
93
94        info!("Downloading source from {}", self.config.source_url);
95
96        // Use reqwest to download
97        let client = reqwest::Client::new();
98        let response = client.get(&self.config.source_url)
99            .send()
100            .await
101            .map_err(|e| Error::Build(BuildError::DownloadFailed {
102                package: self.config.name.clone(),
103                reason: format!("Failed to download: {}", e)
104            }))?;
105
106        if !response.status().is_success() {
107            return Err(Error::Build(BuildError::DownloadFailed {
108                package: self.config.name.clone(),
109                reason: format!("HTTP {}", response.status())
110            }));
111        }
112
113        let bytes = response.bytes()
114            .await
115            .map_err(|e| Error::Build(BuildError::DownloadFailed {
116                package: self.config.name.clone(),
117                reason: format!("Failed to read: {}", e)
118            }))?;
119
120        // Verify checksum
121        let mut hasher = Sha256::new();
122        hasher.update(&bytes);
123        let hash = format!("{:x}", hasher.finalize());
124
125        if hash != self.config.sha256 {
126            return Err(Error::Build(BuildError::DownloadFailed {
127                package: self.config.name.clone(),
128                reason: format!("Checksum mismatch: expected {}, got {}", self.config.sha256, hash)
129            }));
130        }
131
132        std::fs::write(&archive_path, &bytes)?;
133        debug!("Downloaded and verified source archive");
134
135        Ok(archive_path)
136    }
137
138    /// Extract the source archive
139    fn extract_source(&self, archive_path: &Path) -> Result<PathBuf> {
140        use flate2::read::GzDecoder;
141        use tar::Archive;
142
143        info!("Extracting source archive");
144
145        let file = std::fs::File::open(archive_path)?;
146        let decoder = GzDecoder::new(file);
147        let mut archive = Archive::new(decoder);
148
149        // Extract to work directory
150        archive.unpack(&self.work_dir)?;
151
152        // Find the extracted directory (usually name-version)
153        let expected_dir = format!("{}-{}", self.config.name, self.config.version);
154        let source_dir = self.work_dir.join(&expected_dir);
155
156        if source_dir.exists() {
157            return Ok(source_dir);
158        }
159
160        // Try to find any directory that was created
161        for entry in std::fs::read_dir(&self.work_dir)? {
162            let entry = entry?;
163            if entry.file_type()?.is_dir() {
164                let name = entry.file_name();
165                if name.to_string_lossy() != "." && name.to_string_lossy() != ".." {
166                    return Ok(entry.path());
167                }
168            }
169        }
170
171        Err(Error::Build(BuildError::SourceDirectoryNotFound {
172            package: self.config.name.clone()
173        }))
174    }
175
176    /// Run the build process
177    fn run_build(&self, source_dir: &Path) -> Result<PathBuf> {
178        let install_path = self.config.cellar
179            .join(&self.config.name)
180            .join(&self.config.version);
181
182        info!("Building in {:?}", source_dir);
183        info!("Install path: {:?}", install_path);
184
185        // Create install directory
186        std::fs::create_dir_all(&install_path)?;
187
188        // Detect build system and run appropriate commands
189        if source_dir.join("CMakeLists.txt").exists() {
190            self.build_cmake(source_dir, &install_path)?;
191        } else if source_dir.join("configure").exists() {
192            self.build_autotools(source_dir, &install_path)?;
193        } else if source_dir.join("Makefile").exists() {
194            self.build_make(source_dir, &install_path)?;
195        } else if source_dir.join("meson.build").exists() {
196            self.build_meson(source_dir, &install_path)?;
197        } else if source_dir.join("Cargo.toml").exists() {
198            self.build_cargo(source_dir, &install_path)?;
199        } else {
200            return Err(Error::Build(BuildError::unknown_build_system(&self.config.name)));
201        }
202
203        Ok(install_path)
204    }
205
206    /// Build using autotools (configure/make/make install)
207    fn build_autotools(&self, source_dir: &Path, install_path: &Path) -> Result<()> {
208        info!("Using autotools build system");
209
210        let mut configure_cmd = Command::new("./configure");
211        configure_cmd
212            .arg(format!("--prefix={}", install_path.display()))
213            .current_dir(source_dir)
214            .env("HOMEBREW_PREFIX", &self.config.prefix);
215
216        // Set compilers if specified (with validation)
217        if let Some(cc) = &self.config.cc {
218            validate_compiler_path(cc)?;
219            configure_cmd.env("CC", cc);
220        }
221        if let Some(cxx) = &self.config.cxx {
222            validate_compiler_path(cxx)?;
223            configure_cmd.env("CXX", cxx);
224        }
225
226        let configure_status = configure_cmd.status()?;
227
228        if !configure_status.success() {
229            return Err(Error::Build(BuildError::configure_failed(&self.config.name)));
230        }
231
232        // Make
233        let mut make_cmd = Command::new("make");
234        make_cmd
235            .arg("-j")
236            .arg(self.config.get_jobs().to_string())
237            .current_dir(source_dir);
238
239        // Set compilers for make too (with validation)
240        if let Some(cc) = &self.config.cc {
241            validate_compiler_path(cc)?;
242            make_cmd.env("CC", cc);
243        }
244        if let Some(cxx) = &self.config.cxx {
245            validate_compiler_path(cxx)?;
246            make_cmd.env("CXX", cxx);
247        }
248
249        let make_status = make_cmd.status()?;
250
251        if !make_status.success() {
252            return Err(Error::Build(BuildError::make_failed(&self.config.name)));
253        }
254
255        // Make install
256        // Use -- to prevent any argument injection - everything after -- is treated as a target
257        let install_status = Command::new("make")
258            .arg("install")
259            .arg("--")
260            .current_dir(source_dir)
261            .status()?;
262
263        if !install_status.success() {
264            return Err(Error::Build(BuildError::make_install_failed(&self.config.name)));
265        }
266
267        Ok(())
268    }
269
270    /// Build using CMake
271    fn build_cmake(&self, source_dir: &Path, install_path: &Path) -> Result<()> {
272        info!("Using CMake build system");
273
274        let build_dir = source_dir.join("build");
275        std::fs::create_dir_all(&build_dir)?;
276
277        // Configure
278        let mut cmake_cmd = Command::new("cmake");
279        cmake_cmd
280            .arg("..")
281            .arg(format!("-DCMAKE_INSTALL_PREFIX={}", install_path.display()))
282            .arg("-DCMAKE_BUILD_TYPE=Release")
283            .current_dir(&build_dir);
284
285        // Set compilers if specified (with validation)
286        if let Some(cc) = &self.config.cc {
287            validate_compiler_path(cc)?;
288            cmake_cmd.arg(format!("-DCMAKE_C_COMPILER={}", cc));
289        }
290        if let Some(cxx) = &self.config.cxx {
291            validate_compiler_path(cxx)?;
292            cmake_cmd.arg(format!("-DCMAKE_CXX_COMPILER={}", cxx));
293        }
294
295        let cmake_status = cmake_cmd.status()?;
296
297        if !cmake_status.success() {
298            return Err(Error::Build(BuildError::CmakeConfigureFailed {
299                package: self.config.name.clone()
300            }));
301        }
302
303        // Build
304        let build_status = Command::new("cmake")
305            .arg("--build")
306            .arg(".")
307            .arg("-j")
308            .arg(self.config.get_jobs().to_string())
309            .current_dir(&build_dir)
310            .status()?;
311
312        if !build_status.success() {
313            return Err(Error::Build(BuildError::CmakeBuildFailed {
314                package: self.config.name.clone()
315            }));
316        }
317
318        // Install
319        let install_status = Command::new("cmake")
320            .arg("--install")
321            .arg(".")
322            .current_dir(&build_dir)
323            .status()?;
324
325        if !install_status.success() {
326            return Err(Error::Build(BuildError::CmakeInstallFailed {
327                package: self.config.name.clone()
328            }));
329        }
330
331        Ok(())
332    }
333
334    /// Build using plain Makefile
335    fn build_make(&self, source_dir: &Path, install_path: &Path) -> Result<()> {
336        info!("Using Makefile build system");
337
338        // Make
339        let mut make_cmd = Command::new("make");
340        make_cmd
341            .arg("-j")
342            .arg(self.config.get_jobs().to_string())
343            .current_dir(source_dir)
344            .env("PREFIX", install_path);
345
346        // Set compilers if specified (with validation)
347        if let Some(cc) = &self.config.cc {
348            validate_compiler_path(cc)?;
349            make_cmd.env("CC", cc);
350        }
351        if let Some(cxx) = &self.config.cxx {
352            validate_compiler_path(cxx)?;
353            make_cmd.env("CXX", cxx);
354        }
355
356        let make_status = make_cmd.status()?;
357
358        if !make_status.success() {
359            return Err(Error::Build(BuildError::make_failed(&self.config.name)));
360        }
361
362        // Make install
363        let install_status = Command::new("make")
364            .arg("install")
365            .arg(format!("PREFIX={}", install_path.display()))
366            .current_dir(source_dir)
367            .status()?;
368
369        if !install_status.success() {
370            return Err(Error::Build(BuildError::make_install_failed(&self.config.name)));
371        }
372
373        Ok(())
374    }
375
376    /// Build using Meson
377    fn build_meson(&self, source_dir: &Path, install_path: &Path) -> Result<()> {
378        info!("Using Meson build system");
379
380        let build_dir = source_dir.join("build");
381
382        // Setup with compiler options
383        let mut setup_cmd = Command::new("meson");
384        setup_cmd
385            .arg("setup")
386            .arg(&build_dir)
387            .arg(format!("--prefix={}", install_path.display()))
388            .current_dir(source_dir);
389
390        // Set compilers if specified (meson uses CC/CXX env vars, with validation)
391        if let Some(cc) = &self.config.cc {
392            validate_compiler_path(cc)?;
393            setup_cmd.env("CC", cc);
394        }
395        if let Some(cxx) = &self.config.cxx {
396            validate_compiler_path(cxx)?;
397            setup_cmd.env("CXX", cxx);
398        }
399
400        let setup_status = setup_cmd.status()?;
401
402        if !setup_status.success() {
403            return Err(Error::Build(BuildError::MesonConfigureFailed {
404                package: self.config.name.clone()
405            }));
406        }
407
408        // Compile with parallel jobs
409        let compile_status = Command::new("meson")
410            .arg("compile")
411            .arg("-C")
412            .arg(&build_dir)
413            .arg("-j")
414            .arg(self.config.get_jobs().to_string())
415            .status()?;
416
417        if !compile_status.success() {
418            return Err(Error::Build(BuildError::MesonCompileFailed {
419                package: self.config.name.clone()
420            }));
421        }
422
423        // Install
424        let install_status = Command::new("meson")
425            .arg("install")
426            .arg("-C")
427            .arg(&build_dir)
428            .status()?;
429
430        if !install_status.success() {
431            return Err(Error::Build(BuildError::MesonInstallFailed {
432                package: self.config.name.clone()
433            }));
434        }
435
436        Ok(())
437    }
438
439    /// Build using Cargo (Rust)
440    fn build_cargo(&self, source_dir: &Path, install_path: &Path) -> Result<()> {
441        info!("Using Cargo build system");
442
443        // Build release with configurable jobs
444        let build_status = Command::new("cargo")
445            .arg("build")
446            .arg("--release")
447            .arg("-j")
448            .arg(self.config.get_jobs().to_string())
449            .current_dir(source_dir)
450            .status()?;
451
452        if !build_status.success() {
453            return Err(Error::Build(BuildError::CargoBuildFailed {
454                package: self.config.name.clone()
455            }));
456        }
457
458        // Install binaries
459        let bin_dir = install_path.join("bin");
460        std::fs::create_dir_all(&bin_dir)?;
461
462        let release_dir = source_dir.join("target/release");
463        if release_dir.exists() {
464            for entry in std::fs::read_dir(&release_dir)? {
465                let entry = entry?;
466                let path = entry.path();
467                if path.is_file() && is_executable(&path) {
468                    let file_name = path.file_name().unwrap();
469                    // Skip common non-binary files
470                    let name = file_name.to_string_lossy();
471                    if !name.contains('.') && !name.starts_with("lib") {
472                        let dest = bin_dir.join(file_name);
473                        std::fs::copy(&path, &dest)?;
474                        debug!("Installed binary: {:?}", dest);
475                    }
476                }
477            }
478        }
479
480        Ok(())
481    }
482}
483
484/// Check if a file is executable
485fn is_executable(path: &Path) -> bool {
486    use std::os::unix::fs::PermissionsExt;
487    if let Ok(metadata) = path.metadata() {
488        let permissions = metadata.permissions();
489        permissions.mode() & 0o111 != 0
490    } else {
491        false
492    }
493}
494
495/// Validate a compiler path for security
496///
497/// Ensures the path doesn't contain suspicious characters and is safe to use.
498fn validate_compiler_path(path: &str) -> Result<()> {
499    // Check for empty path
500    if path.trim().is_empty() {
501        return Err(Error::Build(BuildError::CompilerValidationFailed {
502            reason: "Compiler path cannot be empty".to_string(),
503        }));
504    }
505
506    // Check for path traversal attempts
507    if path.contains("..") || path.contains(';') || path.contains('|') || path.contains('$') {
508        return Err(Error::Build(BuildError::CompilerValidationFailed {
509            reason: format!("Invalid compiler path '{}': contains suspicious characters", path),
510        }));
511    }
512
513    Ok(())
514}
515
516/// Check if build from source is available for a formula
517pub fn can_build_from_source(source_url: &Option<String>) -> bool {
518    source_url.is_some()
519}