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};
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!(
68            "Building {} {} from source",
69            self.config.name, self.config.version
70        );
71
72        // Create work directory
73        std::fs::create_dir_all(&self.work_dir)?;
74
75        // Download source
76        let archive_path = self.download_source().await?;
77
78        // Extract source
79        let source_dir = self.extract_source(&archive_path)?;
80
81        // Build
82        let install_path = self.run_build(&source_dir)?;
83
84        Ok(BuildResult { install_path })
85    }
86
87    /// Download the source archive
88    async fn download_source(&self) -> Result<PathBuf> {
89        use sha2::{Digest, Sha256};
90
91        let archive_name = self
92            .config
93            .source_url
94            .rsplit('/')
95            .next()
96            .unwrap_or("source.tar.gz");
97        let archive_path = self.work_dir.join(archive_name);
98
99        info!("Downloading source from {}", self.config.source_url);
100
101        // Use reqwest to download
102        let client = reqwest::Client::new();
103        let response = client
104            .get(&self.config.source_url)
105            .send()
106            .await
107            .map_err(|e| {
108                Error::Build(BuildError::DownloadFailed {
109                    package: self.config.name.clone(),
110                    reason: format!("Failed to download: {}", e),
111                })
112            })?;
113
114        if !response.status().is_success() {
115            return Err(Error::Build(BuildError::DownloadFailed {
116                package: self.config.name.clone(),
117                reason: format!("HTTP {}", response.status()),
118            }));
119        }
120
121        let bytes = response.bytes().await.map_err(|e| {
122            Error::Build(BuildError::DownloadFailed {
123                package: self.config.name.clone(),
124                reason: format!("Failed to read: {}", e),
125            })
126        })?;
127
128        // Verify checksum (skip if not provided, e.g., for git-based sources)
129        if !self.config.sha256.is_empty() {
130            let mut hasher = Sha256::new();
131            hasher.update(&bytes);
132            let hash = hex::encode(hasher.finalize());
133
134            if hash != self.config.sha256 {
135                return Err(Error::Build(BuildError::DownloadFailed {
136                    package: self.config.name.clone(),
137                    reason: format!(
138                        "Checksum mismatch: expected {}, got {}",
139                        self.config.sha256, hash
140                    ),
141                }));
142            }
143        } else {
144            debug!("Skipping checksum verification (no sha256 provided)");
145        }
146
147        std::fs::write(&archive_path, &bytes)?;
148        debug!("Downloaded and verified source archive");
149
150        Ok(archive_path)
151    }
152
153    /// Extract the source archive
154    fn extract_source(&self, archive_path: &Path) -> Result<PathBuf> {
155        use flate2::read::GzDecoder;
156        use tar::Archive;
157
158        info!("Extracting source archive");
159
160        let file = std::fs::File::open(archive_path)?;
161        let decoder = GzDecoder::new(file);
162        let mut archive = Archive::new(decoder);
163
164        // Extract to work directory
165        archive.unpack(&self.work_dir)?;
166
167        // Find the extracted directory (usually name-version)
168        let expected_dir = format!("{}-{}", self.config.name, self.config.version);
169        let source_dir = self.work_dir.join(&expected_dir);
170
171        if source_dir.exists() {
172            return Ok(source_dir);
173        }
174
175        // Try to find any directory that was created
176        for entry in std::fs::read_dir(&self.work_dir)? {
177            let entry = entry?;
178            if entry.file_type()?.is_dir() {
179                let name = entry.file_name();
180                if name.to_string_lossy() != "." && name.to_string_lossy() != ".." {
181                    return Ok(entry.path());
182                }
183            }
184        }
185
186        Err(Error::Build(BuildError::SourceDirectoryNotFound {
187            package: self.config.name.clone(),
188        }))
189    }
190
191    /// Run the build process
192    fn run_build(&self, source_dir: &Path) -> Result<PathBuf> {
193        let install_path = self
194            .config
195            .cellar
196            .join(&self.config.name)
197            .join(&self.config.version);
198
199        info!("Building in {:?}", source_dir);
200        info!("Install path: {:?}", install_path);
201
202        // Create install directory
203        std::fs::create_dir_all(&install_path)?;
204
205        let params = BuildParams {
206            name: &self.config.name,
207            prefix: &self.config.prefix,
208            jobs: self.config.get_jobs(),
209            cc: self.config.cc.as_deref(),
210            cxx: self.config.cxx.as_deref(),
211        };
212
213        detect_and_build(&params, source_dir, &install_path)?;
214
215        Ok(install_path)
216    }
217}
218
219/// Validate a compiler path for security
220///
221/// Ensures the path doesn't contain shell metacharacters or injection vectors.
222/// While `Command::new()` doesn't invoke a shell, these paths may appear in
223/// CMake `-D` flags or env vars that downstream tools interpolate.
224fn validate_compiler_path(path: &str) -> Result<()> {
225    // Check for empty path
226    if path.trim().is_empty() {
227        return Err(Error::Build(BuildError::CompilerValidationFailed {
228            reason: "Compiler path cannot be empty".to_string(),
229        }));
230    }
231
232    // Reject shell metacharacters and injection vectors
233    let forbidden = [
234        '!', '$', '`', '|', ';', '&', '(', ')', '{', '}', '\n', '\r', '\0',
235    ];
236    for ch in forbidden {
237        if path.contains(ch) {
238            return Err(Error::Build(BuildError::CompilerValidationFailed {
239                reason: format!(
240                    "Invalid compiler path '{}': contains forbidden character '{}'",
241                    path, ch
242                ),
243            }));
244        }
245    }
246
247    // Check for path traversal
248    if path.contains("..") {
249        return Err(Error::Build(BuildError::CompilerValidationFailed {
250            reason: format!("Invalid compiler path '{}': contains path traversal", path),
251        }));
252    }
253
254    Ok(())
255}
256
257/// Check if build from source is available for a formula
258pub fn can_build_from_source(source_url: &Option<String>) -> bool {
259    source_url.is_some()
260}
261
262/// Check if a file is executable
263fn is_executable(path: &Path) -> bool {
264    use std::os::unix::fs::PermissionsExt;
265    if let Ok(metadata) = path.metadata() {
266        let permissions = metadata.permissions();
267        permissions.mode() & 0o111 != 0
268    } else {
269        false
270    }
271}
272
273// ============================================================================
274// Shared build system implementations
275// ============================================================================
276
277/// Common parameters needed by all build system implementations.
278struct BuildParams<'a> {
279    name: &'a str,
280    prefix: &'a Path,
281    jobs: usize,
282    cc: Option<&'a str>,
283    cxx: Option<&'a str>,
284}
285
286/// Apply validated CC/CXX environment variables to a command.
287fn apply_compiler_env(cmd: &mut Command, params: &BuildParams) -> Result<()> {
288    if let Some(cc) = params.cc {
289        validate_compiler_path(cc)?;
290        cmd.env("CC", cc);
291    }
292    if let Some(cxx) = params.cxx {
293        validate_compiler_path(cxx)?;
294        cmd.env("CXX", cxx);
295    }
296    Ok(())
297}
298
299/// Detect the build system and run the appropriate build steps.
300fn detect_and_build(params: &BuildParams, source_dir: &Path, install_path: &Path) -> Result<()> {
301    if source_dir.join("CMakeLists.txt").exists() {
302        build_cmake(params, source_dir, install_path)
303    } else if source_dir.join("configure").exists() {
304        build_autotools(params, source_dir, install_path)
305    } else if source_dir.join("Makefile").exists() {
306        build_make(params, source_dir, install_path)
307    } else if source_dir.join("meson.build").exists() {
308        build_meson(params, source_dir, install_path)
309    } else if source_dir.join("Cargo.toml").exists() {
310        build_cargo(params, source_dir, install_path)
311    } else {
312        Err(Error::Build(BuildError::unknown_build_system(params.name)))
313    }
314}
315
316/// Run autogen.sh to generate a configure script.
317fn run_autogen(params: &BuildParams, source_dir: &Path) -> Result<()> {
318    info!("Running autogen.sh");
319
320    let status = Command::new("./autogen.sh")
321        .current_dir(source_dir)
322        .status()?;
323
324    if !status.success() {
325        return Err(Error::Build(BuildError::ConfigureFailed {
326            package: params.name.to_string(),
327        }));
328    }
329
330    Ok(())
331}
332
333/// Build using autotools (configure/make/make install).
334fn build_autotools(params: &BuildParams, source_dir: &Path, install_path: &Path) -> Result<()> {
335    info!("Using autotools build system");
336
337    let mut configure_cmd = Command::new("./configure");
338    configure_cmd
339        .arg(format!("--prefix={}", install_path.display()))
340        .current_dir(source_dir)
341        .env("HOMEBREW_PREFIX", params.prefix);
342    apply_compiler_env(&mut configure_cmd, params)?;
343
344    if !configure_cmd.status()?.success() {
345        return Err(Error::Build(BuildError::configure_failed(params.name)));
346    }
347
348    let mut make_cmd = Command::new("make");
349    make_cmd
350        .arg("-j")
351        .arg(params.jobs.to_string())
352        .current_dir(source_dir);
353    apply_compiler_env(&mut make_cmd, params)?;
354
355    if !make_cmd.status()?.success() {
356        return Err(Error::Build(BuildError::make_failed(params.name)));
357    }
358
359    let install_status = Command::new("make")
360        .arg("install")
361        .arg("--")
362        .current_dir(source_dir)
363        .status()?;
364
365    if !install_status.success() {
366        return Err(Error::Build(BuildError::make_install_failed(params.name)));
367    }
368
369    Ok(())
370}
371
372/// Build using CMake.
373fn build_cmake(params: &BuildParams, source_dir: &Path, install_path: &Path) -> Result<()> {
374    info!("Using CMake build system");
375
376    let build_dir = source_dir.join("build");
377    std::fs::create_dir_all(&build_dir)?;
378
379    let mut cmake_cmd = Command::new("cmake");
380    cmake_cmd
381        .arg("..")
382        .arg(format!("-DCMAKE_INSTALL_PREFIX={}", install_path.display()))
383        .arg("-DCMAKE_BUILD_TYPE=Release")
384        .current_dir(&build_dir);
385
386    if let Some(cc) = params.cc {
387        validate_compiler_path(cc)?;
388        cmake_cmd.arg(format!("-DCMAKE_C_COMPILER={}", cc));
389    }
390    if let Some(cxx) = params.cxx {
391        validate_compiler_path(cxx)?;
392        cmake_cmd.arg(format!("-DCMAKE_CXX_COMPILER={}", cxx));
393    }
394
395    if !cmake_cmd.status()?.success() {
396        return Err(Error::Build(BuildError::CmakeConfigureFailed {
397            package: params.name.to_string(),
398        }));
399    }
400
401    let build_status = Command::new("cmake")
402        .arg("--build")
403        .arg(".")
404        .arg("-j")
405        .arg(params.jobs.to_string())
406        .current_dir(&build_dir)
407        .status()?;
408
409    if !build_status.success() {
410        return Err(Error::Build(BuildError::CmakeBuildFailed {
411            package: params.name.to_string(),
412        }));
413    }
414
415    let install_status = Command::new("cmake")
416        .arg("--install")
417        .arg(".")
418        .current_dir(&build_dir)
419        .status()?;
420
421    if !install_status.success() {
422        return Err(Error::Build(BuildError::CmakeInstallFailed {
423            package: params.name.to_string(),
424        }));
425    }
426
427    Ok(())
428}
429
430/// Build using plain Makefile.
431fn build_make(params: &BuildParams, source_dir: &Path, install_path: &Path) -> Result<()> {
432    info!("Using Makefile build system");
433
434    // Pass PREFIX as a make argument so it overrides Makefile `:=`
435    // assignments. Use the Cellar install path as PREFIX (matching Homebrew's
436    // convention). This ensures files are installed directly into the Cellar
437    // and the runtime prefix resolves through Homebrew/stout symlinks.
438    let prefix_arg = format!("PREFIX={}", install_path.display());
439
440    let mut make_cmd = Command::new("make");
441    make_cmd
442        .arg("-j")
443        .arg(params.jobs.to_string())
444        .arg(&prefix_arg)
445        .current_dir(source_dir);
446    apply_compiler_env(&mut make_cmd, params)?;
447
448    if !make_cmd.status()?.success() {
449        return Err(Error::Build(BuildError::make_failed(params.name)));
450    }
451
452    let install_status = Command::new("make")
453        .arg("install")
454        .arg(&prefix_arg)
455        .current_dir(source_dir)
456        .status()?;
457
458    if !install_status.success() {
459        return Err(Error::Build(BuildError::make_install_failed(params.name)));
460    }
461
462    Ok(())
463}
464
465/// Build using Meson.
466fn build_meson(params: &BuildParams, source_dir: &Path, install_path: &Path) -> Result<()> {
467    info!("Using Meson build system");
468
469    let build_dir = source_dir.join("build");
470
471    let mut setup_cmd = Command::new("meson");
472    setup_cmd
473        .arg("setup")
474        .arg(&build_dir)
475        .arg(format!("--prefix={}", install_path.display()))
476        .current_dir(source_dir);
477    apply_compiler_env(&mut setup_cmd, params)?;
478
479    if !setup_cmd.status()?.success() {
480        return Err(Error::Build(BuildError::MesonConfigureFailed {
481            package: params.name.to_string(),
482        }));
483    }
484
485    let compile_status = Command::new("meson")
486        .arg("compile")
487        .arg("-C")
488        .arg(&build_dir)
489        .arg("-j")
490        .arg(params.jobs.to_string())
491        .status()?;
492
493    if !compile_status.success() {
494        return Err(Error::Build(BuildError::MesonCompileFailed {
495            package: params.name.to_string(),
496        }));
497    }
498
499    let install_status = Command::new("meson")
500        .arg("install")
501        .arg("-C")
502        .arg(&build_dir)
503        .status()?;
504
505    if !install_status.success() {
506        return Err(Error::Build(BuildError::MesonInstallFailed {
507            package: params.name.to_string(),
508        }));
509    }
510
511    Ok(())
512}
513
514/// Build using Cargo (Rust).
515fn build_cargo(params: &BuildParams, source_dir: &Path, install_path: &Path) -> Result<()> {
516    info!("Using Cargo build system");
517
518    let build_status = Command::new("cargo")
519        .arg("build")
520        .arg("--release")
521        .arg("-j")
522        .arg(params.jobs.to_string())
523        .current_dir(source_dir)
524        .status()?;
525
526    if !build_status.success() {
527        return Err(Error::Build(BuildError::CargoBuildFailed {
528            package: params.name.to_string(),
529        }));
530    }
531
532    let bin_dir = install_path.join("bin");
533    std::fs::create_dir_all(&bin_dir)?;
534
535    let release_dir = source_dir.join("target/release");
536    if release_dir.exists() {
537        for entry in std::fs::read_dir(&release_dir)? {
538            let entry = entry?;
539            let path = entry.path();
540            if path.is_file() && is_executable(&path) {
541                let file_name = path.file_name().unwrap();
542                let name = file_name.to_string_lossy();
543                if !name.contains('.') && !name.starts_with("lib") {
544                    let dest = bin_dir.join(file_name);
545                    std::fs::copy(&path, &dest)?;
546                    debug!("Installed binary: {:?}", dest);
547                }
548            }
549        }
550    }
551
552    Ok(())
553}
554
555// ============================================================================
556// HEAD Build Support
557// ============================================================================
558
559/// Configuration for building from HEAD (git)
560#[derive(Debug, Clone)]
561pub struct HeadBuildConfig {
562    /// Git repository URL
563    pub git_url: String,
564    /// Branch to clone (default: "master")
565    pub branch: String,
566    /// Formula name
567    pub name: String,
568    /// Homebrew prefix
569    pub prefix: PathBuf,
570    /// Cellar path
571    pub cellar: PathBuf,
572    /// Number of parallel build jobs
573    pub jobs: Option<usize>,
574    /// C compiler
575    pub cc: Option<String>,
576    /// C++ compiler
577    pub cxx: Option<String>,
578}
579
580impl HeadBuildConfig {
581    /// Get the number of parallel jobs to use
582    pub fn get_jobs(&self) -> usize {
583        self.jobs.unwrap_or_else(num_cpus::get)
584    }
585}
586
587/// Result of a HEAD build
588#[derive(Debug)]
589pub struct HeadBuildResult {
590    /// Path to installed package
591    pub install_path: PathBuf,
592    /// Full commit SHA
593    pub commit_sha: String,
594    /// Short SHA (7 chars)
595    pub short_sha: String,
596}
597
598/// Builder for HEAD (git) installations
599pub struct HeadBuilder {
600    config: HeadBuildConfig,
601    work_dir: PathBuf,
602}
603
604impl HeadBuilder {
605    /// Create a new HEAD builder
606    pub fn new(config: HeadBuildConfig, work_dir: impl AsRef<Path>) -> Self {
607        Self {
608            config,
609            work_dir: work_dir.as_ref().to_path_buf(),
610        }
611    }
612
613    /// Build from HEAD git repository
614    pub async fn build(&self) -> Result<HeadBuildResult> {
615        info!(
616            "Building {} from HEAD ({})",
617            self.config.name, self.config.git_url
618        );
619
620        // Create work directory
621        std::fs::create_dir_all(&self.work_dir)?;
622
623        // Clone repository
624        let repo_dir = self.clone_repository()?;
625
626        // Get commit SHA
627        let (full_sha, short_sha) = self.get_commit_sha(&repo_dir)?;
628        info!("HEAD commit: {} ({})", short_sha, full_sha);
629
630        // Build using detected build system
631        let install_path = self.run_build(&repo_dir, &short_sha)?;
632
633        Ok(HeadBuildResult {
634            install_path,
635            commit_sha: full_sha,
636            short_sha,
637        })
638    }
639
640    /// Clone the git repository
641    fn clone_repository(&self) -> Result<PathBuf> {
642        let repo_dir = self.work_dir.join(&self.config.name);
643
644        // Remove existing directory if present
645        if repo_dir.exists() {
646            debug!("Removing existing repository directory");
647            std::fs::remove_dir_all(&repo_dir)?;
648        }
649
650        info!("Cloning {}...", self.config.git_url);
651
652        // Clone with depth 1 for efficiency (we just need the latest)
653        let mut args = vec!["clone", "--depth", "1"];
654
655        // Add branch if not default
656        if !self.config.branch.is_empty()
657            && self.config.branch != "master"
658            && self.config.branch != "main"
659        {
660            args.extend_from_slice(&["--branch", &self.config.branch]);
661        }
662
663        args.extend_from_slice(&[&self.config.git_url, repo_dir.to_str().unwrap()]);
664
665        let status = Command::new("git").args(&args).status().map_err(|e| {
666            Error::Build(BuildError::GitCloneFailed {
667                package: self.config.name.clone(),
668                reason: e.to_string(),
669            })
670        })?;
671
672        if !status.success() {
673            return Err(Error::Build(BuildError::GitCloneFailed {
674                package: self.config.name.clone(),
675                reason: "git clone returned non-zero exit code".to_string(),
676            }));
677        }
678
679        debug!("Repository cloned to {:?}", repo_dir);
680        Ok(repo_dir)
681    }
682
683    /// Get the current commit SHA from the cloned repository
684    fn get_commit_sha(&self, repo_dir: &Path) -> Result<(String, String)> {
685        let output = Command::new("git")
686            .args(["rev-parse", "HEAD"])
687            .current_dir(repo_dir)
688            .output()
689            .map_err(|e| {
690                Error::Build(BuildError::GitFailed {
691                    package: self.config.name.clone(),
692                    reason: e.to_string(),
693                })
694            })?;
695
696        if !output.status.success() {
697            return Err(Error::Build(BuildError::GitFailed {
698                package: self.config.name.clone(),
699                reason: "Failed to get commit SHA".to_string(),
700            }));
701        }
702
703        let full_sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
704        let short_sha: String = full_sha.chars().take(7).collect();
705
706        Ok((full_sha, short_sha))
707    }
708
709    /// Build using detected build system
710    fn run_build(&self, source_dir: &Path, short_sha: &str) -> Result<PathBuf> {
711        let install_path = self
712            .config
713            .cellar
714            .join(&self.config.name)
715            .join(format!("HEAD-{}", short_sha));
716
717        info!("Building in {:?}", source_dir);
718        info!("Install path: {:?}", install_path);
719
720        // Create install directory
721        std::fs::create_dir_all(&install_path)?;
722
723        let params = BuildParams {
724            name: &self.config.name,
725            prefix: &self.config.prefix,
726            jobs: self.config.get_jobs(),
727            cc: self.config.cc.as_deref(),
728            cxx: self.config.cxx.as_deref(),
729        };
730
731        // HEAD builds also check for autogen.sh before configure
732        if source_dir.join("autogen.sh").exists() && !source_dir.join("configure").exists() {
733            run_autogen(&params, source_dir)?;
734        }
735
736        detect_and_build(&params, source_dir, &install_path)?;
737
738        Ok(install_path)
739    }
740}