bob/config.rs
1/*
2 * Copyright (c) 2025 Jonathan Perkin <jonathan@perkin.org.uk>
3 *
4 * Permission to use, copy, modify, and distribute this software for any
5 * purpose with or without fee is hereby granted, provided that the above
6 * copyright notice and this permission notice appear in all copies.
7 *
8 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
15 */
16
17//! Configuration file parsing (Lua format).
18//!
19//! Bob uses Lua configuration files for maximum flexibility. The configuration
20//! defines paths to pkgsrc, packages to build, sandbox setup, and build scripts.
21//!
22//! # Configuration File Structure
23//!
24//! A configuration file has four main sections:
25//!
26//! - [`options`](#options-section) - General build options (optional)
27//! - [`pkgsrc`](#pkgsrc-section) - pkgsrc paths and package list (required)
28//! - [`scripts`](#scripts-section) - Build script paths (required)
29//! - [`sandboxes`](#sandboxes-section) - Sandbox configuration (optional)
30//!
31//! # Options Section
32//!
33//! The `options` section is optional. All fields have defaults.
34//!
35//! | Field | Type | Default | Description |
36//! |-------|------|---------|-------------|
37//! | `build_threads` | integer | 1 | Number of parallel build sandboxes. Each sandbox builds one package at a time. |
38//! | `scan_threads` | integer | 1 | Number of parallel scan processes for dependency discovery. |
39//! | `strict_scan` | boolean | false | If true, abort on scan errors. If false, continue and report failures separately. |
40//! | `verbose` | boolean | false | Enable verbose output. Can be overridden by the `-v` command line flag. |
41//!
42//! # Pkgsrc Section
43//!
44//! The `pkgsrc` section is required and defines paths to pkgsrc components.
45//!
46//! ## Required Fields
47//!
48//! | Field | Type | Description |
49//! |-------|------|-------------|
50//! | `basedir` | string | Absolute path to the pkgsrc source tree (e.g., `/data/pkgsrc`). |
51//! | `logdir` | string | Directory for all logs. Per-package build logs go in subdirectories. Failed builds leave logs here; successful builds clean up. |
52//! | `make` | string | Absolute path to the bmake binary (e.g., `/usr/pkg/bin/bmake`). |
53//! | `packages` | string | Directory where binary packages are stored after successful builds. |
54//! | `pkgtools` | string | Directory containing `pkg_add`, `pkg_delete`, and other pkg tools (e.g., `/usr/pkg/sbin`). |
55//! | `prefix` | string | Installation prefix for packages (e.g., `/usr/pkg`). Must match the bootstrap kit. |
56//! | `tar` | string | Absolute path to a tar binary capable of extracting the bootstrap kit. |
57//!
58//! ## Optional Fields
59//!
60//! | Field | Type | Default | Description |
61//! |-------|------|---------|-------------|
62//! | `bootstrap` | string | none | Path to a bootstrap tarball. Required on non-NetBSD systems. Unpacked into each sandbox before builds. |
63//! | `build_user` | string | none | Unprivileged user to run builds as. If set, builds run as this user instead of root. |
64//! | `pkgpaths` | table | `{}` | List of package paths to build (e.g., `{"mail/mutt", "www/curl"}`). Dependencies are discovered automatically. |
65//! | `report_dir` | string | `logdir` | Directory for HTML build reports. Defaults to the `logdir` directory. |
66//! | `save_wrkdir_patterns` | table | `{}` | Glob patterns for files to preserve from WRKDIR on build failure (e.g., `{"**/config.log"}`). |
67//! | `env` | function or table | `{}` | Environment variables for builds. Can be a table of key-value pairs, or a function receiving package metadata and returning a table. See [Environment Function](#environment-function). |
68//!
69//! ## Environment Function
70//!
71//! The `env` field can be a function that returns environment variables for each
72//! package build. The function receives a `pkg` table with the following fields:
73//!
74//! | Field | Type | Description |
75//! |-------|------|-------------|
76//! | `pkgname` | string | Package name with version (e.g., `mutt-2.2.12`). |
77//! | `pkgpath` | string | Package path in pkgsrc (e.g., `mail/mutt`). |
78//! | `all_depends` | string | Space-separated list of all transitive dependency paths. |
79//! | `depends` | string | Space-separated list of direct dependency package names. |
80//! | `scan_depends` | string | Space-separated list of scan-time dependency paths. |
81//! | `categories` | string | Package categories from `CATEGORIES`. |
82//! | `maintainer` | string | Package maintainer email from `MAINTAINER`. |
83//! | `bootstrap_pkg` | string | Value of `BOOTSTRAP_PKG` if set. |
84//! | `usergroup_phase` | string | Value of `USERGROUP_PHASE` if set. |
85//! | `use_destdir` | string | Value of `USE_DESTDIR`. |
86//! | `multi_version` | string | Value of `MULTI_VERSION` if set. |
87//! | `pbulk_weight` | string | Value of `PBULK_WEIGHT` if set. |
88//! | `pkg_skip_reason` | string | Value of `PKG_SKIP_REASON` if set. |
89//! | `pkg_fail_reason` | string | Value of `PKG_FAIL_REASON` if set. |
90//! | `no_bin_on_ftp` | string | Value of `NO_BIN_ON_FTP` if set. |
91//! | `restricted` | string | Value of `RESTRICTED` if set. |
92//!
93//! # Scripts Section
94//!
95//! The `scripts` section defines paths to build scripts. Relative paths are
96//! resolved from the configuration file's directory.
97//!
98//! | Script | Required | Description |
99//! |--------|----------|-------------|
100//! | `pre-build` | no | Executed before each package build. Used for per-build sandbox setup (e.g., unpacking bootstrap kit). Receives environment variables listed in [Script Environment](#script-environment). |
101//! | `post-build` | no | Executed after each package build completes (success or failure). |
102//!
103//! ## Script Environment
104//!
105//! Build scripts receive these environment variables:
106//!
107//! | Variable | Description |
108//! |----------|-------------|
109//! | `bob_logdir` | Path to the log directory. |
110//! | `bob_make` | Path to the bmake binary. |
111//! | `bob_packages` | Path to the packages directory. |
112//! | `bob_pkgtools` | Path to the pkg tools directory. |
113//! | `bob_pkgsrc` | Path to the pkgsrc source tree. |
114//! | `bob_prefix` | Installation prefix. |
115//! | `bob_tar` | Path to the tar binary. |
116//! | `bob_build_user` | Unprivileged build user, if configured. |
117//! | `bob_bootstrap` | Path to the bootstrap tarball, if configured. |
118//! | `bob_status_fd` | File descriptor for sending status messages back to bob. |
119//!
120//! ## Status Messages
121//!
122//! Scripts can send status updates to bob by writing to the file descriptor
123//! in `bob_status_fd`:
124//!
125//! | Message | Description |
126//! |---------|-------------|
127//! | `stage:<name>` | Build entered a new phase (e.g., `stage:configure`). Displayed in the TUI. |
128//! | `skipped` | Package was skipped (e.g., already up-to-date). |
129//!
130//! # Sandboxes Section
131//!
132//! The `sandboxes` section is optional. When present, builds run in isolated
133//! chroot environments.
134//!
135//! | Field | Type | Required | Description |
136//! |-------|------|----------|-------------|
137//! | `basedir` | string | yes | Base directory for sandbox roots. Sandboxes are created as numbered subdirectories (`basedir/0`, `basedir/1`, etc.). |
138//! | `actions` | table | yes | List of actions to perform during sandbox setup. See the [`action`](crate::action) module for details. |
139
140use crate::action::Action;
141use crate::scan::ResolvedIndex;
142use anyhow::{Context, Result, anyhow};
143use mlua::{Lua, RegistryKey, Result as LuaResult, Table, Value};
144use pkgsrc::PkgPath;
145use std::collections::HashMap;
146use std::path::{Path, PathBuf};
147use std::sync::{Arc, Mutex};
148
149/// Holds the Lua state for evaluating env functions.
150#[derive(Clone)]
151pub struct LuaEnv {
152 lua: Arc<Mutex<Lua>>,
153 env_key: Option<Arc<RegistryKey>>,
154}
155
156impl std::fmt::Debug for LuaEnv {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 f.debug_struct("LuaEnv")
159 .field("has_env", &self.env_key.is_some())
160 .finish()
161 }
162}
163
164impl Default for LuaEnv {
165 fn default() -> Self {
166 Self { lua: Arc::new(Mutex::new(Lua::new())), env_key: None }
167 }
168}
169
170impl LuaEnv {
171 /// Get environment variables for a package by calling the env function.
172 /// Returns a HashMap of VAR_NAME -> value.
173 pub fn get_env(
174 &self,
175 idx: &ResolvedIndex,
176 ) -> Result<HashMap<String, String>, String> {
177 let Some(env_key) = &self.env_key else {
178 return Ok(HashMap::new());
179 };
180
181 let lua =
182 self.lua.lock().map_err(|e| format!("Lua lock error: {}", e))?;
183
184 // Get the env value from registry
185 let env_value: Value = lua
186 .registry_value(env_key)
187 .map_err(|e| format!("Failed to get env from registry: {}", e))?;
188
189 let result_table: Table = match env_value {
190 // If it's a function, call it with pkg info
191 Value::Function(func) => {
192 let pkg_table = lua
193 .create_table()
194 .map_err(|e| format!("Failed to create table: {}", e))?;
195
196 // Set all ScanIndex fields
197 pkg_table
198 .set("pkgname", idx.pkgname.to_string())
199 .map_err(|e| format!("Failed to set pkgname: {}", e))?;
200 pkg_table
201 .set(
202 "pkgpath",
203 idx.pkg_location
204 .as_ref()
205 .map(|p| p.as_path().display().to_string())
206 .unwrap_or_default(),
207 )
208 .map_err(|e| format!("Failed to set pkgpath: {}", e))?;
209 pkg_table
210 .set(
211 "all_depends",
212 idx.all_depends
213 .as_ref()
214 .map(|deps| {
215 deps.iter()
216 .map(|d| {
217 d.pkgpath()
218 .as_path()
219 .display()
220 .to_string()
221 })
222 .collect::<Vec<_>>()
223 .join(" ")
224 })
225 .unwrap_or_default(),
226 )
227 .map_err(|e| format!("Failed to set all_depends: {}", e))?;
228 pkg_table
229 .set(
230 "pkg_skip_reason",
231 idx.pkg_skip_reason.clone().unwrap_or_default(),
232 )
233 .map_err(|e| {
234 format!("Failed to set pkg_skip_reason: {}", e)
235 })?;
236 pkg_table
237 .set(
238 "pkg_fail_reason",
239 idx.pkg_fail_reason.clone().unwrap_or_default(),
240 )
241 .map_err(|e| {
242 format!("Failed to set pkg_fail_reason: {}", e)
243 })?;
244 pkg_table
245 .set(
246 "no_bin_on_ftp",
247 idx.no_bin_on_ftp.clone().unwrap_or_default(),
248 )
249 .map_err(|e| {
250 format!("Failed to set no_bin_on_ftp: {}", e)
251 })?;
252 pkg_table
253 .set(
254 "restricted",
255 idx.restricted.clone().unwrap_or_default(),
256 )
257 .map_err(|e| format!("Failed to set restricted: {}", e))?;
258 pkg_table
259 .set(
260 "categories",
261 idx.categories.clone().unwrap_or_default(),
262 )
263 .map_err(|e| format!("Failed to set categories: {}", e))?;
264 pkg_table
265 .set(
266 "maintainer",
267 idx.maintainer.clone().unwrap_or_default(),
268 )
269 .map_err(|e| format!("Failed to set maintainer: {}", e))?;
270 pkg_table
271 .set(
272 "use_destdir",
273 idx.use_destdir.clone().unwrap_or_default(),
274 )
275 .map_err(|e| format!("Failed to set use_destdir: {}", e))?;
276 pkg_table
277 .set(
278 "bootstrap_pkg",
279 idx.bootstrap_pkg.clone().unwrap_or_default(),
280 )
281 .map_err(|e| {
282 format!("Failed to set bootstrap_pkg: {}", e)
283 })?;
284 pkg_table
285 .set(
286 "usergroup_phase",
287 idx.usergroup_phase.clone().unwrap_or_default(),
288 )
289 .map_err(|e| {
290 format!("Failed to set usergroup_phase: {}", e)
291 })?;
292 pkg_table
293 .set(
294 "scan_depends",
295 idx.scan_depends
296 .as_ref()
297 .map(|deps| {
298 deps.iter()
299 .map(|p| p.display().to_string())
300 .collect::<Vec<_>>()
301 .join(" ")
302 })
303 .unwrap_or_default(),
304 )
305 .map_err(|e| {
306 format!("Failed to set scan_depends: {}", e)
307 })?;
308 pkg_table
309 .set(
310 "pbulk_weight",
311 idx.pbulk_weight.clone().unwrap_or_default(),
312 )
313 .map_err(|e| {
314 format!("Failed to set pbulk_weight: {}", e)
315 })?;
316 pkg_table
317 .set(
318 "multi_version",
319 idx.multi_version
320 .as_ref()
321 .map(|v| v.join(" "))
322 .unwrap_or_default(),
323 )
324 .map_err(|e| {
325 format!("Failed to set multi_version: {}", e)
326 })?;
327 pkg_table
328 .set(
329 "depends",
330 idx.depends
331 .iter()
332 .map(|d| d.to_string())
333 .collect::<Vec<_>>()
334 .join(" "),
335 )
336 .map_err(|e| format!("Failed to set depends: {}", e))?;
337
338 func.call(pkg_table).map_err(|e| {
339 format!("Failed to call env function: {}", e)
340 })?
341 }
342 // If it's a table, use it directly
343 Value::Table(t) => t,
344 Value::Nil => return Ok(HashMap::new()),
345 _ => return Err("env must be a function or table".to_string()),
346 };
347
348 // Convert Lua table to HashMap
349 let mut env = HashMap::new();
350 for pair in result_table.pairs::<String, String>() {
351 let (k, v) = pair
352 .map_err(|e| format!("Failed to iterate env table: {}", e))?;
353 env.insert(k, v);
354 }
355
356 Ok(env)
357 }
358}
359
360/// Main configuration structure.
361///
362/// Load configuration using [`Config::load`], then access settings through
363/// the provided methods.
364///
365/// # Example
366///
367/// ```no_run
368/// use bob::Config;
369/// use std::path::Path;
370///
371/// let config = Config::load(Some(Path::new("/data/bob/config.lua")), false)?;
372/// println!("Building with {} threads", config.build_threads());
373/// # Ok::<(), anyhow::Error>(())
374/// ```
375#[derive(Clone, Debug, Default)]
376pub struct Config {
377 file: ConfigFile,
378 filename: PathBuf,
379 verbose: bool,
380 lua_env: LuaEnv,
381}
382
383/// Parsed configuration file contents.
384#[derive(Clone, Debug, Default)]
385pub struct ConfigFile {
386 /// The `options` section.
387 pub options: Option<Options>,
388 /// The `pkgsrc` section.
389 pub pkgsrc: Pkgsrc,
390 /// The `scripts` section (script name -> path).
391 pub scripts: HashMap<String, PathBuf>,
392 /// The `sandboxes` section.
393 pub sandboxes: Option<Sandboxes>,
394}
395
396/// General build options from the `options` section.
397///
398/// All fields are optional; defaults are used when not specified:
399/// - `build_threads`: 1
400/// - `scan_threads`: 1
401/// - `verbose`: false
402#[derive(Clone, Debug, Default)]
403pub struct Options {
404 /// Number of parallel build sandboxes.
405 pub build_threads: Option<usize>,
406 /// Number of parallel scan processes.
407 pub scan_threads: Option<usize>,
408 /// If true, abort on scan errors. If false, continue and report failures.
409 pub strict_scan: Option<bool>,
410 /// Enable verbose output.
411 pub verbose: Option<bool>,
412}
413
414/// pkgsrc-related configuration from the `pkgsrc` section.
415///
416/// # Required Fields
417///
418/// - `basedir`: Path to pkgsrc source tree
419/// - `logdir`: Directory for logs
420/// - `make`: Path to bmake binary
421/// - `packages`: Directory for built packages
422/// - `pkgtools`: Directory containing pkg_add/pkg_delete
423/// - `prefix`: Installation prefix (e.g., `/usr/pkg`)
424/// - `tar`: Path to tar binary
425///
426/// # Optional Fields
427///
428/// - `bootstrap`: Path to bootstrap tarball (required on non-NetBSD systems)
429/// - `build_user`: Unprivileged user for builds
430/// - `pkgpaths`: List of packages to build
431/// - `report_dir`: Directory for HTML reports
432/// - `save_wrkdir_patterns`: Glob patterns for files to save on build failure
433#[derive(Clone, Debug, Default)]
434pub struct Pkgsrc {
435 /// Path to pkgsrc source tree.
436 pub basedir: PathBuf,
437 /// Path to bootstrap tarball (required on non-NetBSD).
438 pub bootstrap: Option<PathBuf>,
439 /// Unprivileged user for builds.
440 pub build_user: Option<String>,
441 /// Directory for logs.
442 pub logdir: PathBuf,
443 /// Path to bmake binary.
444 pub make: PathBuf,
445 /// Directory for built packages.
446 pub packages: PathBuf,
447 /// Directory containing pkg_add/pkg_delete.
448 pub pkgtools: PathBuf,
449 /// List of packages to build.
450 pub pkgpaths: Option<Vec<PkgPath>>,
451 /// Installation prefix.
452 pub prefix: PathBuf,
453 /// Directory for HTML reports.
454 pub report_dir: Option<PathBuf>,
455 /// Glob patterns for files to save from WRKDIR on failure.
456 pub save_wrkdir_patterns: Vec<String>,
457 /// Environment variables for scan processes.
458 pub scanenv: HashMap<String, String>,
459 /// Path to tar binary.
460 pub tar: PathBuf,
461}
462
463/// Sandbox configuration from the `sandboxes` section.
464///
465/// When this section is present in the configuration, builds are performed
466/// in isolated chroot environments.
467///
468/// # Example
469///
470/// ```lua
471/// sandboxes = {
472/// basedir = "/data/chroot/bob",
473/// actions = {
474/// { action = "mount", fs = "proc", dir = "/proc" },
475/// { action = "copy", dir = "/etc" },
476/// },
477/// }
478/// ```
479#[derive(Clone, Debug, Default)]
480pub struct Sandboxes {
481 /// Base directory for sandbox roots (e.g., `/data/chroot/bob`).
482 ///
483 /// Individual sandboxes are created as numbered subdirectories:
484 /// `basedir/0`, `basedir/1`, etc.
485 pub basedir: PathBuf,
486 /// Actions to perform during sandbox setup/teardown.
487 ///
488 /// See [`Action`] for details.
489 pub actions: Vec<Action>,
490}
491
492impl Config {
493 /// Load configuration from a Lua file.
494 ///
495 /// # Arguments
496 ///
497 /// * `config_path` - Path to configuration file, or `None` to use `./config.lua`
498 /// * `verbose` - Enable verbose output (overrides config file setting)
499 ///
500 /// # Errors
501 ///
502 /// Returns an error if the configuration file doesn't exist or contains
503 /// invalid Lua syntax.
504 pub fn load(config_path: Option<&Path>, verbose: bool) -> Result<Config> {
505 /*
506 * Load user-supplied configuration file, or the default location.
507 */
508 let filename = if let Some(path) = config_path {
509 path.to_path_buf()
510 } else {
511 std::env::current_dir()
512 .context("Unable to determine current directory")?
513 .join("config.lua")
514 };
515
516 /* A configuration file is mandatory. */
517 if !filename.exists() {
518 anyhow::bail!(
519 "Configuration file {} does not exist",
520 filename.display()
521 );
522 }
523
524 /*
525 * Parse configuration file as Lua.
526 */
527 let (mut file, lua_env) =
528 load_lua(&filename).map_err(|e| anyhow!(e)).with_context(|| {
529 format!(
530 "Unable to parse Lua configuration file {}",
531 filename.display()
532 )
533 })?;
534
535 /*
536 * Parse scripts section. Paths are resolved relative to config dir
537 * if not absolute.
538 */
539 let base_dir = filename.parent().unwrap_or_else(|| Path::new("."));
540 let mut newscripts: HashMap<String, PathBuf> = HashMap::new();
541 for (k, v) in &file.scripts {
542 let fullpath =
543 if v.is_relative() { base_dir.join(v) } else { v.clone() };
544 newscripts.insert(k.clone(), fullpath);
545 }
546 file.scripts = newscripts;
547
548 /*
549 * Validate bootstrap path exists if specified.
550 */
551 if let Some(ref bootstrap) = file.pkgsrc.bootstrap {
552 if !bootstrap.exists() {
553 anyhow::bail!(
554 "pkgsrc.bootstrap file {} does not exist",
555 bootstrap.display()
556 );
557 }
558 }
559
560 /*
561 * Set verbose from command line option, falling back to config file.
562 */
563 let verbose = if verbose {
564 true
565 } else if let Some(v) = &file.options {
566 v.verbose.unwrap_or(false)
567 } else {
568 false
569 };
570
571 Ok(Config { file, filename, verbose, lua_env })
572 }
573
574 pub fn build_threads(&self) -> usize {
575 if let Some(opts) = &self.file.options {
576 opts.build_threads.unwrap_or(1)
577 } else {
578 1
579 }
580 }
581
582 pub fn scan_threads(&self) -> usize {
583 if let Some(opts) = &self.file.options {
584 opts.scan_threads.unwrap_or(1)
585 } else {
586 1
587 }
588 }
589
590 pub fn strict_scan(&self) -> bool {
591 if let Some(opts) = &self.file.options {
592 opts.strict_scan.unwrap_or(false)
593 } else {
594 false
595 }
596 }
597
598 pub fn script(&self, key: &str) -> Option<&PathBuf> {
599 self.file.scripts.get(key)
600 }
601
602 pub fn make(&self) -> &PathBuf {
603 &self.file.pkgsrc.make
604 }
605
606 pub fn pkgpaths(&self) -> &Option<Vec<PkgPath>> {
607 &self.file.pkgsrc.pkgpaths
608 }
609
610 pub fn pkgsrc(&self) -> &PathBuf {
611 &self.file.pkgsrc.basedir
612 }
613
614 pub fn sandboxes(&self) -> &Option<Sandboxes> {
615 &self.file.sandboxes
616 }
617
618 pub fn verbose(&self) -> bool {
619 self.verbose
620 }
621
622 /// Return the path to the configuration file.
623 pub fn config_path(&self) -> Option<&Path> {
624 if self.filename.as_os_str().is_empty() {
625 None
626 } else {
627 Some(&self.filename)
628 }
629 }
630
631 pub fn logdir(&self) -> &PathBuf {
632 &self.file.pkgsrc.logdir
633 }
634
635 pub fn packages(&self) -> &PathBuf {
636 &self.file.pkgsrc.packages
637 }
638
639 pub fn pkgtools(&self) -> &PathBuf {
640 &self.file.pkgsrc.pkgtools
641 }
642
643 pub fn prefix(&self) -> &PathBuf {
644 &self.file.pkgsrc.prefix
645 }
646
647 #[allow(dead_code)]
648 pub fn report_dir(&self) -> Option<&PathBuf> {
649 self.file.pkgsrc.report_dir.as_ref()
650 }
651
652 pub fn save_wrkdir_patterns(&self) -> &[String] {
653 self.file.pkgsrc.save_wrkdir_patterns.as_slice()
654 }
655
656 pub fn tar(&self) -> &PathBuf {
657 &self.file.pkgsrc.tar
658 }
659
660 pub fn build_user(&self) -> Option<&str> {
661 self.file.pkgsrc.build_user.as_deref()
662 }
663
664 pub fn bootstrap(&self) -> Option<&PathBuf> {
665 self.file.pkgsrc.bootstrap.as_ref()
666 }
667
668 /// Get environment variables for a package from the Lua env function/table.
669 pub fn get_pkg_env(
670 &self,
671 idx: &ResolvedIndex,
672 ) -> Result<std::collections::HashMap<String, String>, String> {
673 self.lua_env.get_env(idx)
674 }
675
676 /// Return environment variables for script execution.
677 pub fn script_env(&self) -> Vec<(String, String)> {
678 let mut envs = vec![
679 ("bob_logdir".to_string(), format!("{}", self.logdir().display())),
680 ("bob_make".to_string(), format!("{}", self.make().display())),
681 (
682 "bob_packages".to_string(),
683 format!("{}", self.packages().display()),
684 ),
685 (
686 "bob_pkgtools".to_string(),
687 format!("{}", self.pkgtools().display()),
688 ),
689 ("bob_pkgsrc".to_string(), format!("{}", self.pkgsrc().display())),
690 ("bob_prefix".to_string(), format!("{}", self.prefix().display())),
691 ("bob_tar".to_string(), format!("{}", self.tar().display())),
692 ];
693 if let Some(build_user) = self.build_user() {
694 envs.push(("bob_build_user".to_string(), build_user.to_string()));
695 }
696 if let Some(bootstrap) = self.bootstrap() {
697 envs.push((
698 "bob_bootstrap".to_string(),
699 format!("{}", bootstrap.display()),
700 ));
701 }
702 envs
703 }
704
705 /// Return environment variables for scan processes.
706 pub fn scan_env(&self) -> Vec<(String, String)> {
707 self.file
708 .pkgsrc
709 .scanenv
710 .iter()
711 .map(|(k, v)| (k.clone(), v.clone()))
712 .collect()
713 }
714
715 /// Validate the configuration, checking that required paths and files exist.
716 pub fn validate(&self) -> Result<(), Vec<String>> {
717 let mut errors: Vec<String> = Vec::new();
718
719 // Check pkgsrc directory exists
720 if !self.file.pkgsrc.basedir.exists() {
721 errors.push(format!(
722 "pkgsrc basedir does not exist: {}",
723 self.file.pkgsrc.basedir.display()
724 ));
725 }
726
727 // Check make binary exists (only on host if sandboxes not enabled)
728 // When sandboxes are enabled, the make binary is inside the sandbox
729 if self.file.sandboxes.is_none() && !self.file.pkgsrc.make.exists() {
730 errors.push(format!(
731 "make binary does not exist: {}",
732 self.file.pkgsrc.make.display()
733 ));
734 }
735
736 // Check scripts exist
737 for (name, path) in &self.file.scripts {
738 if !path.exists() {
739 errors.push(format!(
740 "Script '{}' does not exist: {}",
741 name,
742 path.display()
743 ));
744 } else if !path.is_file() {
745 errors.push(format!(
746 "Script '{}' is not a file: {}",
747 name,
748 path.display()
749 ));
750 }
751 }
752
753 // Check sandbox basedir is writable if sandboxes enabled
754 if let Some(sandboxes) = &self.file.sandboxes {
755 // Check parent directory exists or can be created
756 if let Some(parent) = sandboxes.basedir.parent() {
757 if !parent.exists() {
758 errors.push(format!(
759 "Sandbox basedir parent does not exist: {}",
760 parent.display()
761 ));
762 }
763 }
764 }
765
766 // Check logdir can be created
767 if let Some(parent) = self.file.pkgsrc.logdir.parent() {
768 if !parent.exists() {
769 errors.push(format!(
770 "logdir parent directory does not exist: {}",
771 parent.display()
772 ));
773 }
774 }
775
776 // Check packages dir can be created
777 if let Some(parent) = self.file.pkgsrc.packages.parent() {
778 if !parent.exists() {
779 errors.push(format!(
780 "Packages parent directory does not exist: {}",
781 parent.display()
782 ));
783 }
784 }
785
786 if errors.is_empty() { Ok(()) } else { Err(errors) }
787 }
788}
789
790/// Load a Lua configuration file and return a ConfigFile and LuaEnv.
791fn load_lua(filename: &Path) -> Result<(ConfigFile, LuaEnv), String> {
792 let lua = Lua::new();
793
794 // Add config directory to package.path so require() finds relative modules
795 if let Some(config_dir) = filename.parent() {
796 let path_setup = format!(
797 "package.path = '{}' .. '/?.lua;' .. package.path",
798 config_dir.display()
799 );
800 lua.load(&path_setup)
801 .exec()
802 .map_err(|e| format!("Failed to set package.path: {}", e))?;
803 }
804
805 lua.load(filename)
806 .exec()
807 .map_err(|e| format!("Lua execution error: {}", e))?;
808
809 // Get the global table (Lua script should set global variables)
810 let globals = lua.globals();
811
812 // Parse each section
813 let options = parse_options(&globals)
814 .map_err(|e| format!("Error parsing options: {}", e))?;
815 let pkgsrc_table: Table = globals
816 .get("pkgsrc")
817 .map_err(|e| format!("Error getting pkgsrc: {}", e))?;
818 let pkgsrc = parse_pkgsrc(&globals)
819 .map_err(|e| format!("Error parsing pkgsrc: {}", e))?;
820 let scripts = parse_scripts(&globals)
821 .map_err(|e| format!("Error parsing scripts: {}", e))?;
822 let sandboxes = parse_sandboxes(&globals)
823 .map_err(|e| format!("Error parsing sandboxes: {}", e))?;
824
825 // Store env function/table in registry if it exists
826 let env_key = if let Ok(env_value) = pkgsrc_table.get::<Value>("env") {
827 if !env_value.is_nil() {
828 let key = lua.create_registry_value(env_value).map_err(|e| {
829 format!("Failed to store env in registry: {}", e)
830 })?;
831 Some(Arc::new(key))
832 } else {
833 None
834 }
835 } else {
836 None
837 };
838
839 let lua_env = LuaEnv { lua: Arc::new(Mutex::new(lua)), env_key };
840
841 let config = ConfigFile { options, pkgsrc, scripts, sandboxes };
842
843 Ok((config, lua_env))
844}
845
846fn parse_options(globals: &Table) -> LuaResult<Option<Options>> {
847 let options: Value = globals.get("options")?;
848 if options.is_nil() {
849 return Ok(None);
850 }
851
852 let table = options
853 .as_table()
854 .ok_or_else(|| mlua::Error::runtime("options must be a table"))?;
855
856 Ok(Some(Options {
857 build_threads: table.get("build_threads").ok(),
858 scan_threads: table.get("scan_threads").ok(),
859 strict_scan: table.get("strict_scan").ok(),
860 verbose: table.get("verbose").ok(),
861 }))
862}
863
864fn parse_pkgsrc(globals: &Table) -> LuaResult<Pkgsrc> {
865 let pkgsrc: Table = globals.get("pkgsrc")?;
866
867 let basedir: String = pkgsrc.get("basedir")?;
868 let bootstrap: Option<PathBuf> =
869 pkgsrc.get::<Option<String>>("bootstrap")?.map(PathBuf::from);
870 let build_user: Option<String> =
871 pkgsrc.get::<Option<String>>("build_user")?;
872 let logdir: String = pkgsrc.get("logdir")?;
873 let make: String = pkgsrc.get("make")?;
874 let packages: String = pkgsrc.get("packages")?;
875 let pkgtools: String = pkgsrc.get("pkgtools")?;
876 let prefix: String = pkgsrc.get("prefix")?;
877 let tar: String = pkgsrc.get("tar")?;
878
879 let pkgpaths: Option<Vec<PkgPath>> =
880 match pkgsrc.get::<Value>("pkgpaths")? {
881 Value::Nil => None,
882 Value::Table(t) => {
883 let paths: Vec<PkgPath> = t
884 .sequence_values::<String>()
885 .filter_map(|r| r.ok())
886 .filter_map(|s| PkgPath::new(&s).ok())
887 .collect();
888 if paths.is_empty() { None } else { Some(paths) }
889 }
890 _ => None,
891 };
892
893 let report_dir: Option<PathBuf> =
894 pkgsrc.get::<Option<String>>("report_dir")?.map(PathBuf::from);
895
896 let save_wrkdir_patterns: Vec<String> =
897 match pkgsrc.get::<Value>("save_wrkdir_patterns")? {
898 Value::Nil => Vec::new(),
899 Value::Table(t) => {
900 t.sequence_values::<String>().filter_map(|r| r.ok()).collect()
901 }
902 _ => Vec::new(),
903 };
904
905 let scanenv: HashMap<String, String> =
906 match pkgsrc.get::<Value>("scanenv")? {
907 Value::Nil => HashMap::new(),
908 Value::Table(t) => {
909 t.pairs::<String, String>().filter_map(|r| r.ok()).collect()
910 }
911 _ => HashMap::new(),
912 };
913
914 Ok(Pkgsrc {
915 basedir: PathBuf::from(basedir),
916 bootstrap,
917 build_user,
918 logdir: PathBuf::from(logdir),
919 make: PathBuf::from(make),
920 packages: PathBuf::from(packages),
921 pkgtools: PathBuf::from(pkgtools),
922 pkgpaths,
923 prefix: PathBuf::from(prefix),
924 report_dir,
925 save_wrkdir_patterns,
926 scanenv,
927 tar: PathBuf::from(tar),
928 })
929}
930
931fn parse_scripts(globals: &Table) -> LuaResult<HashMap<String, PathBuf>> {
932 let scripts: Value = globals.get("scripts")?;
933 if scripts.is_nil() {
934 return Ok(HashMap::new());
935 }
936
937 let table = scripts
938 .as_table()
939 .ok_or_else(|| mlua::Error::runtime("scripts must be a table"))?;
940
941 let mut result = HashMap::new();
942 for pair in table.pairs::<String, String>() {
943 let (k, v) = pair?;
944 result.insert(k, PathBuf::from(v));
945 }
946
947 Ok(result)
948}
949
950fn parse_sandboxes(globals: &Table) -> LuaResult<Option<Sandboxes>> {
951 let sandboxes: Value = globals.get("sandboxes")?;
952 if sandboxes.is_nil() {
953 return Ok(None);
954 }
955
956 let table = sandboxes
957 .as_table()
958 .ok_or_else(|| mlua::Error::runtime("sandboxes must be a table"))?;
959
960 let basedir: String = table.get("basedir")?;
961
962 let actions_value: Value = table.get("actions")?;
963 let actions = if actions_value.is_nil() {
964 Vec::new()
965 } else {
966 let actions_table = actions_value.as_table().ok_or_else(|| {
967 mlua::Error::runtime("sandboxes.actions must be a table")
968 })?;
969 parse_actions(actions_table)?
970 };
971
972 Ok(Some(Sandboxes { basedir: PathBuf::from(basedir), actions }))
973}
974
975fn parse_actions(table: &Table) -> LuaResult<Vec<Action>> {
976 table.sequence_values::<Table>().map(|v| Action::from_lua(&v?)).collect()
977}