1use crate::error::{BuildError, Error, Result};
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use tracing::{debug, info};
10
11#[derive(Debug, Clone)]
13pub struct BuildConfig {
14 pub source_url: String,
16 pub sha256: String,
18 pub name: String,
20 pub version: String,
22 pub prefix: PathBuf,
24 pub cellar: PathBuf,
26 pub build_deps: Vec<String>,
28 pub jobs: Option<usize>,
30 pub cc: Option<String>,
32 pub cxx: Option<String>,
34}
35
36impl BuildConfig {
37 pub fn get_jobs(&self) -> usize {
39 self.jobs.unwrap_or_else(num_cpus::get)
40 }
41}
42
43#[derive(Debug)]
45pub struct BuildResult {
46 pub install_path: PathBuf,
48}
49
50pub struct SourceBuilder {
52 config: BuildConfig,
53 work_dir: PathBuf,
54}
55
56impl SourceBuilder {
57 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 pub async fn build(&self) -> Result<BuildResult> {
67 info!(
68 "Building {} {} from source",
69 self.config.name, self.config.version
70 );
71
72 std::fs::create_dir_all(&self.work_dir)?;
74
75 let archive_path = self.download_source().await?;
77
78 let source_dir = self.extract_source(&archive_path)?;
80
81 let install_path = self.run_build(&source_dir)?;
83
84 Ok(BuildResult { install_path })
85 }
86
87 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 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 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 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 archive.unpack(&self.work_dir)?;
166
167 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 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 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 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(¶ms, source_dir, &install_path)?;
214
215 Ok(install_path)
216 }
217}
218
219fn validate_compiler_path(path: &str) -> Result<()> {
225 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 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 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
257pub fn can_build_from_source(source_url: &Option<String>) -> bool {
259 source_url.is_some()
260}
261
262fn 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
273struct 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
286fn 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
299fn 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
316fn 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
333fn 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
372fn 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
430fn build_make(params: &BuildParams, source_dir: &Path, install_path: &Path) -> Result<()> {
432 info!("Using Makefile build system");
433
434 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
465fn 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
514fn 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#[derive(Debug, Clone)]
561pub struct HeadBuildConfig {
562 pub git_url: String,
564 pub branch: String,
566 pub name: String,
568 pub prefix: PathBuf,
570 pub cellar: PathBuf,
572 pub jobs: Option<usize>,
574 pub cc: Option<String>,
576 pub cxx: Option<String>,
578}
579
580impl HeadBuildConfig {
581 pub fn get_jobs(&self) -> usize {
583 self.jobs.unwrap_or_else(num_cpus::get)
584 }
585}
586
587#[derive(Debug)]
589pub struct HeadBuildResult {
590 pub install_path: PathBuf,
592 pub commit_sha: String,
594 pub short_sha: String,
596}
597
598pub struct HeadBuilder {
600 config: HeadBuildConfig,
601 work_dir: PathBuf,
602}
603
604impl HeadBuilder {
605 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 pub async fn build(&self) -> Result<HeadBuildResult> {
615 info!(
616 "Building {} from HEAD ({})",
617 self.config.name, self.config.git_url
618 );
619
620 std::fs::create_dir_all(&self.work_dir)?;
622
623 let repo_dir = self.clone_repository()?;
625
626 let (full_sha, short_sha) = self.get_commit_sha(&repo_dir)?;
628 info!("HEAD commit: {} ({})", short_sha, full_sha);
629
630 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 fn clone_repository(&self) -> Result<PathBuf> {
642 let repo_dir = self.work_dir.join(&self.config.name);
643
644 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 let mut args = vec!["clone", "--depth", "1"];
654
655 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 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 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 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 if source_dir.join("autogen.sh").exists() && !source_dir.join("configure").exists() {
733 run_autogen(¶ms, source_dir)?;
734 }
735
736 detect_and_build(¶ms, source_dir, &install_path)?;
737
738 Ok(install_path)
739 }
740}