Skip to main content

bob/
config.rs

1/*
2 * Copyright (c) 2026 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 five main sections:
25//!
26//! - [`options`](#options-section) - General build options (optional)
27//! - [`environment`](#environment-section) - Environment variable configuration (optional)
28//! - [`pkgsrc`](#pkgsrc-section) - pkgsrc paths and package list (required)
29//! - [`scripts`](#scripts-section) - Build script paths (required)
30//! - [`sandboxes`](#sandboxes-section) - Sandbox configuration (optional)
31//!
32//! # Options Section
33//!
34//! The `options` section is optional. All fields have defaults.
35//!
36//! | Field | Type | Default | Description |
37//! |-------|------|---------|-------------|
38//! | `build_threads` | integer | 1 | Number of parallel build sandboxes. Each sandbox builds one package at a time. |
39//! | `scan_threads` | integer | 1 | Number of parallel scan processes for dependency discovery. |
40//! | `strict_scan` | boolean | false | If true, abort on scan errors. If false, continue and report failures separately. |
41//! | `log_level` | string | "info" | Log level: "trace", "debug", "info", "warn", or "error". Can be overridden by `RUST_LOG` env var. |
42//!
43//! # Environment Section
44//!
45//! The `environment` section is optional. It controls the environment variables
46//! available to processes executed inside sandboxes.
47//!
48//! If this section is omitted, the parent environment is inherited unchanged.
49//! If present, `clear` defaults to true and the environment is cleared before
50//! applying the configured variables.
51//!
52//! | Field | Type | Default | Description |
53//! |-------|------|---------|-------------|
54//! | `clear` | boolean | true | If true, clear the environment. If false, inherit the full parent environment. |
55//! | `inherit` | table | `{}` | Variable names to copy from the parent environment (only used when `clear = true`). |
56//! | `set` | table | `{}` | Variables to set explicitly as key-value pairs. |
57//!
58//! To configure a minimal, controlled environment:
59//!
60//! ```lua
61//! environment = {
62//!     inherit = { "TERM", "HOME" },
63//!     set = {
64//!         PATH = "/sbin:/bin:/usr/sbin:/usr/bin",
65//!     },
66//! }
67//! ```
68//!
69//! ## Precedence
70//!
71//! Variables are applied in this order (later values override earlier):
72//!
73//! 1. `inherit` - copied from parent process (only if `clear = true`)
74//! 2. `set` - explicitly configured values
75//! 3. `pkgsrc.cachevars` - values fetched from pkgsrc
76//! 4. `pkgsrc.env` - per-package overrides
77//! 5. `bob_*` - internal variables (always set, cannot be overridden)
78//!
79//! # Pkgsrc Section
80//!
81//! The `pkgsrc` section is required and defines paths to pkgsrc components.
82//!
83//! ## Required Fields
84//!
85//! | Field | Type | Description |
86//! |-------|------|-------------|
87//! | `basedir` | string | Absolute path to the pkgsrc source tree (e.g., `/data/pkgsrc`). |
88//! | `logdir` | string | Directory for all logs. Per-package build logs go in subdirectories. Failed builds leave logs here; successful builds clean up. |
89//! | `make` | string | Absolute path to the bmake binary (e.g., `/usr/pkg/bin/bmake`). |
90//!
91//! ## Optional Fields
92//!
93//! | Field | Type | Default | Description |
94//! |-------|------|---------|-------------|
95//! | `bootstrap` | string | none | Path to a bootstrap tarball. Required on non-NetBSD systems. Unpacked into each sandbox before builds. |
96//! | `build_user` | string | none | Unprivileged user to run builds as. If set, builds run as this user instead of root. |
97//! | `cachevars` | table | `{}` | List of pkgsrc variable names to fetch once and cache. These are set in the environment for scans and builds (e.g., `{"NATIVE_OPSYS", "NATIVE_OS_VERSION"}`). |
98//! | `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). |
99//! | `pkgpaths` | table | `{}` | List of package paths to build (e.g., `{"mail/mutt", "www/curl"}`). Dependencies are discovered automatically. |
100//! | `save_wrkdir_patterns` | table | `{}` | Glob patterns for files to preserve from WRKDIR on build failure (e.g., `{"**/config.log"}`). |
101//! | `tar` | string | `tar` | Path to a tar binary capable of extracting the bootstrap kit. Defaults to `tar` in PATH. |
102//!
103//! ## Environment Function
104//!
105//! The `env` field can be a function that returns environment variables for each
106//! package build. The function receives a `pkg` table with the following fields:
107//!
108//! | Field | Type | Description |
109//! |-------|------|-------------|
110//! | `pkgname` | string | Package name with version (e.g., `mutt-2.2.12`). |
111//! | `pkgpath` | string | Package path in pkgsrc (e.g., `mail/mutt`). |
112//! | `all_depends` | string | Space-separated list of all transitive dependency paths. |
113//! | `depends` | string | Space-separated list of direct dependency package names. |
114//! | `scan_depends` | string | Space-separated list of scan-time dependency paths. |
115//! | `categories` | string | Package categories from `CATEGORIES`. |
116//! | `maintainer` | string | Package maintainer email from `MAINTAINER`. |
117//! | `bootstrap_pkg` | string | Value of `BOOTSTRAP_PKG` if set. |
118//! | `usergroup_phase` | string | Value of `USERGROUP_PHASE` if set. |
119//! | `use_destdir` | string | Value of `USE_DESTDIR`. |
120//! | `multi_version` | string | Value of `MULTI_VERSION` if set. |
121//! | `pbulk_weight` | string | Value of `PBULK_WEIGHT` if set. |
122//! | `pkg_skip_reason` | string | Value of `PKG_SKIP_REASON` if set. |
123//! | `pkg_fail_reason` | string | Value of `PKG_FAIL_REASON` if set. |
124//! | `no_bin_on_ftp` | string | Value of `NO_BIN_ON_FTP` if set. |
125//! | `restricted` | string | Value of `RESTRICTED` if set. |
126//!
127//! # Scripts Section
128//!
129//! The `scripts` section defines paths to build scripts. Relative paths are
130//! resolved from the configuration file's directory.
131//!
132//! | Script | Required | Description |
133//! |--------|----------|-------------|
134//! | `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). |
135//! | `post-build` | no | Executed after each package build completes (success or failure). |
136//!
137//! ## Script Environment
138//!
139//! Build scripts receive these environment variables:
140//!
141//! | Variable | Description |
142//! |----------|-------------|
143//! | `bob_logdir` | Path to the log directory. |
144//! | `bob_make` | Path to the bmake binary. |
145//! | `bob_packages` | Path to the packages directory. |
146//! | `bob_pkg_dbdir` | PKG_DBDIR from pkgsrc. |
147//! | `bob_pkg_refcount_dbdir` | PKG_REFCOUNT_DBDIR from pkgsrc. |
148//! | `bob_pkgtools` | Path to the pkg tools directory. |
149//! | `bob_pkgsrc` | Path to the pkgsrc source tree. |
150//! | `bob_prefix` | Installation prefix. |
151//! | `bob_tar` | Path to the tar binary. |
152//! | `bob_build_user` | Unprivileged build user, if configured. |
153//! | `bob_bootstrap` | Path to the bootstrap tarball, if configured. |
154//!
155//! # Sandboxes Section
156//!
157//! The `sandboxes` section is optional. When present, builds run in isolated
158//! chroot environments.
159//!
160//! | Field | Type | Required | Description |
161//! |-------|------|----------|-------------|
162//! | `basedir` | string | yes | Base directory for sandbox roots. Sandboxes are created as numbered subdirectories (`basedir/0`, `basedir/1`, etc.). |
163//! | `actions` | table | yes | List of actions to perform during sandbox setup. See the [`action`](crate::action) module for details. |
164
165use crate::action::Action;
166use crate::sandbox::Sandbox;
167use crate::scan::ResolvedPackage;
168use anyhow::{Context, Result, anyhow, bail};
169use mlua::{Lua, RegistryKey, Result as LuaResult, Table, Value};
170use pkgsrc::PkgPath;
171use std::collections::HashMap;
172use std::path::{Path, PathBuf};
173use std::sync::{Arc, Mutex};
174
175/// Environment variables retrieved from pkgsrc.
176///
177/// These values are queried from pkgsrc's mk.conf via bmake and represent
178/// the actual paths pkgsrc is configured to use. This struct is created
179/// after sandbox setup and passed to build operations.
180#[derive(Clone, Debug)]
181pub struct PkgsrcEnv {
182    /// PACKAGES directory for binary packages.
183    pub packages: PathBuf,
184    /// PKG_TOOLS_BIN directory containing pkg_add, pkg_delete, etc.
185    pub pkgtools: PathBuf,
186    /// PREFIX installation directory.
187    pub prefix: PathBuf,
188    /// PKG_DBDIR for installed package database.
189    pub pkg_dbdir: PathBuf,
190    /// PKG_REFCOUNT_DBDIR for refcounted files database.
191    pub pkg_refcount_dbdir: PathBuf,
192    /// Cached pkgsrc variables from the `cachevars` config option.
193    pub cachevars: HashMap<String, String>,
194}
195
196impl PkgsrcEnv {
197    /// Fetch pkgsrc environment variables by querying bmake.
198    ///
199    /// This must be called after sandbox 0 is created if sandboxes are enabled,
200    /// since bmake may only exist inside the sandbox.
201    pub fn fetch(config: &Config, sandbox: &Sandbox) -> Result<Self> {
202        const REQUIRED_VARS: &[&str] = &[
203            "PACKAGES",
204            "PKG_DBDIR",
205            "PKG_REFCOUNT_DBDIR",
206            "PKG_TOOLS_BIN",
207            "PREFIX",
208        ];
209
210        let user_cachevars = config.cachevars();
211        let mut all_varnames: Vec<&str> = REQUIRED_VARS.to_vec();
212        for v in user_cachevars {
213            all_varnames.push(v.as_str());
214        }
215
216        let varnames_arg = all_varnames.join(" ");
217        let script = format!(
218            "cd {}/pkgtools/pkg_install && {} show-vars VARNAMES=\"{}\"\n",
219            config.pkgsrc().display(),
220            config.make().display(),
221            varnames_arg
222        );
223
224        let child = sandbox.execute_script(0, &script, vec![])?;
225        let output = child
226            .wait_with_output()
227            .context("Failed to execute bmake show-vars")?;
228
229        if !output.status.success() {
230            let stderr = String::from_utf8_lossy(&output.stderr);
231            bail!("Failed to query pkgsrc variables: {}", stderr.trim());
232        }
233
234        let stdout = String::from_utf8_lossy(&output.stdout);
235        let lines: Vec<&str> = stdout.lines().collect();
236
237        if lines.len() != all_varnames.len() {
238            bail!(
239                "Expected {} variables from pkgsrc, got {}",
240                all_varnames.len(),
241                lines.len()
242            );
243        }
244
245        let mut values: HashMap<&str, &str> = HashMap::new();
246        for (varname, value) in all_varnames.iter().zip(&lines) {
247            values.insert(varname, value);
248        }
249
250        for varname in REQUIRED_VARS {
251            if values.get(varname).is_none_or(|v| v.is_empty()) {
252                bail!("pkgsrc returned empty value for {}", varname);
253            }
254        }
255
256        let mut cachevars: HashMap<String, String> = HashMap::new();
257        for varname in user_cachevars {
258            if let Some(value) = values.get(varname.as_str()) {
259                if !value.is_empty() {
260                    cachevars.insert(varname.clone(), (*value).to_string());
261                }
262            }
263        }
264
265        Ok(PkgsrcEnv {
266            packages: PathBuf::from(values["PACKAGES"]),
267            pkgtools: PathBuf::from(values["PKG_TOOLS_BIN"]),
268            prefix: PathBuf::from(values["PREFIX"]),
269            pkg_dbdir: PathBuf::from(values["PKG_DBDIR"]),
270            pkg_refcount_dbdir: PathBuf::from(values["PKG_REFCOUNT_DBDIR"]),
271            cachevars,
272        })
273    }
274}
275
276/// Holds the Lua state for evaluating env functions.
277#[derive(Clone)]
278pub struct LuaEnv {
279    lua: Arc<Mutex<Lua>>,
280    env_key: Option<Arc<RegistryKey>>,
281}
282
283impl std::fmt::Debug for LuaEnv {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        f.debug_struct("LuaEnv")
286            .field("has_env", &self.env_key.is_some())
287            .finish()
288    }
289}
290
291impl Default for LuaEnv {
292    fn default() -> Self {
293        Self {
294            lua: Arc::new(Mutex::new(Lua::new())),
295            env_key: None,
296        }
297    }
298}
299
300impl LuaEnv {
301    /// Get environment variables for a package by calling the env function.
302    /// Returns a HashMap of VAR_NAME -> value.
303    pub fn get_env(&self, pkg: &ResolvedPackage) -> Result<HashMap<String, String>, String> {
304        let Some(env_key) = &self.env_key else {
305            return Ok(HashMap::new());
306        };
307
308        let lua = self
309            .lua
310            .lock()
311            .map_err(|e| format!("Lua lock error: {}", e))?;
312
313        // Get the env value from registry
314        let env_value: Value = lua
315            .registry_value(env_key)
316            .map_err(|e| format!("Failed to get env from registry: {}", e))?;
317
318        let idx = &pkg.index;
319
320        let result_table: Table = match env_value {
321            // If it's a function, call it with pkg info
322            Value::Function(func) => {
323                let pkg_table = lua
324                    .create_table()
325                    .map_err(|e| format!("Failed to create table: {}", e))?;
326
327                // Set all ScanIndex fields
328                pkg_table
329                    .set("pkgname", idx.pkgname.to_string())
330                    .map_err(|e| format!("Failed to set pkgname: {}", e))?;
331                pkg_table
332                    .set("pkgpath", pkg.pkgpath.as_path().display().to_string())
333                    .map_err(|e| format!("Failed to set pkgpath: {}", e))?;
334                pkg_table
335                    .set(
336                        "all_depends",
337                        idx.all_depends
338                            .as_ref()
339                            .map(|deps| {
340                                deps.iter()
341                                    .map(|d| d.pkgpath().as_path().display().to_string())
342                                    .collect::<Vec<_>>()
343                                    .join(" ")
344                            })
345                            .unwrap_or_default(),
346                    )
347                    .map_err(|e| format!("Failed to set all_depends: {}", e))?;
348                pkg_table
349                    .set(
350                        "pkg_skip_reason",
351                        idx.pkg_skip_reason.clone().unwrap_or_default(),
352                    )
353                    .map_err(|e| format!("Failed to set pkg_skip_reason: {}", e))?;
354                pkg_table
355                    .set(
356                        "pkg_fail_reason",
357                        idx.pkg_fail_reason.clone().unwrap_or_default(),
358                    )
359                    .map_err(|e| format!("Failed to set pkg_fail_reason: {}", e))?;
360                pkg_table
361                    .set(
362                        "no_bin_on_ftp",
363                        idx.no_bin_on_ftp.clone().unwrap_or_default(),
364                    )
365                    .map_err(|e| format!("Failed to set no_bin_on_ftp: {}", e))?;
366                pkg_table
367                    .set("restricted", idx.restricted.clone().unwrap_or_default())
368                    .map_err(|e| format!("Failed to set restricted: {}", e))?;
369                pkg_table
370                    .set("categories", idx.categories.clone().unwrap_or_default())
371                    .map_err(|e| format!("Failed to set categories: {}", e))?;
372                pkg_table
373                    .set("maintainer", idx.maintainer.clone().unwrap_or_default())
374                    .map_err(|e| format!("Failed to set maintainer: {}", e))?;
375                pkg_table
376                    .set("use_destdir", idx.use_destdir.clone().unwrap_or_default())
377                    .map_err(|e| format!("Failed to set use_destdir: {}", e))?;
378                pkg_table
379                    .set(
380                        "bootstrap_pkg",
381                        idx.bootstrap_pkg.clone().unwrap_or_default(),
382                    )
383                    .map_err(|e| format!("Failed to set bootstrap_pkg: {}", e))?;
384                pkg_table
385                    .set(
386                        "usergroup_phase",
387                        idx.usergroup_phase.clone().unwrap_or_default(),
388                    )
389                    .map_err(|e| format!("Failed to set usergroup_phase: {}", e))?;
390                pkg_table
391                    .set(
392                        "scan_depends",
393                        idx.scan_depends
394                            .as_ref()
395                            .map(|deps| {
396                                deps.iter()
397                                    .map(|p| p.display().to_string())
398                                    .collect::<Vec<_>>()
399                                    .join(" ")
400                            })
401                            .unwrap_or_default(),
402                    )
403                    .map_err(|e| format!("Failed to set scan_depends: {}", e))?;
404                pkg_table
405                    .set("pbulk_weight", idx.pbulk_weight.clone().unwrap_or_default())
406                    .map_err(|e| format!("Failed to set pbulk_weight: {}", e))?;
407                pkg_table
408                    .set(
409                        "multi_version",
410                        idx.multi_version
411                            .as_ref()
412                            .map(|v| v.join(" "))
413                            .unwrap_or_default(),
414                    )
415                    .map_err(|e| format!("Failed to set multi_version: {}", e))?;
416                pkg_table
417                    .set(
418                        "depends",
419                        pkg.depends()
420                            .iter()
421                            .map(|d| d.to_string())
422                            .collect::<Vec<_>>()
423                            .join(" "),
424                    )
425                    .map_err(|e| format!("Failed to set depends: {}", e))?;
426
427                func.call(pkg_table)
428                    .map_err(|e| format!("Failed to call env function: {}", e))?
429            }
430            // If it's a table, use it directly
431            Value::Table(t) => t,
432            Value::Nil => return Ok(HashMap::new()),
433            _ => return Err("env must be a function or table".to_string()),
434        };
435
436        // Convert Lua table to HashMap
437        let mut env = HashMap::new();
438        for pair in result_table.pairs::<String, String>() {
439            let (k, v) = pair.map_err(|e| format!("Failed to iterate env table: {}", e))?;
440            env.insert(k, v);
441        }
442
443        Ok(env)
444    }
445}
446
447/// Main configuration structure.
448#[derive(Clone, Debug, Default)]
449pub struct Config {
450    file: ConfigFile,
451    log_level: String,
452    lua_env: LuaEnv,
453}
454
455/// Parsed configuration file contents.
456#[derive(Clone, Debug, Default)]
457pub struct ConfigFile {
458    /// The `options` section.
459    pub options: Option<Options>,
460    /// The `pkgsrc` section.
461    pub pkgsrc: Pkgsrc,
462    /// The `scripts` section (script name -> path).
463    pub scripts: HashMap<String, PathBuf>,
464    /// The `sandboxes` section.
465    pub sandboxes: Option<Sandboxes>,
466    /// The `environment` section.
467    pub environment: Option<Environment>,
468}
469
470/// General build options from the `options` section.
471///
472/// All fields are optional; defaults are used when not specified:
473/// - `build_threads`: 1
474/// - `scan_threads`: 1
475/// - `log_level`: "info"
476#[derive(Clone, Debug, Default)]
477pub struct Options {
478    /// Number of parallel build sandboxes.
479    pub build_threads: Option<usize>,
480    /// Number of parallel scan processes.
481    pub scan_threads: Option<usize>,
482    /// If true, abort on scan errors. If false, continue and report failures.
483    pub strict_scan: Option<bool>,
484    /// Log level: "trace", "debug", "info", "warn", or "error".
485    pub log_level: Option<String>,
486}
487
488/// pkgsrc-related configuration from the `pkgsrc` section.
489///
490/// # Required Fields
491///
492/// - `basedir`: Path to pkgsrc source tree
493/// - `logdir`: Directory for logs
494/// - `make`: Path to bmake binary
495///
496/// # Optional Fields
497///
498/// - `bootstrap`: Path to bootstrap tarball (required on non-NetBSD systems)
499/// - `build_user`: Unprivileged user for builds
500/// - `pkgpaths`: List of packages to build
501/// - `save_wrkdir_patterns`: Glob patterns for files to save on build failure
502/// - `tar`: Path to tar binary (defaults to `tar`)
503#[derive(Clone, Debug, Default)]
504pub struct Pkgsrc {
505    /// Path to pkgsrc source tree.
506    pub basedir: PathBuf,
507    /// Path to bootstrap tarball (required on non-NetBSD).
508    pub bootstrap: Option<PathBuf>,
509    /// Unprivileged user for builds.
510    pub build_user: Option<String>,
511    /// Directory for logs.
512    pub logdir: PathBuf,
513    /// Path to bmake binary.
514    pub make: PathBuf,
515    /// List of packages to build.
516    pub pkgpaths: Option<Vec<PkgPath>>,
517    /// Glob patterns for files to save from WRKDIR on failure.
518    pub save_wrkdir_patterns: Vec<String>,
519    /// pkgsrc variables to cache and re-set in each environment run.
520    pub cachevars: Vec<String>,
521    /// Path to tar binary (defaults to `tar` in PATH).
522    pub tar: Option<PathBuf>,
523}
524
525/// Environment configuration from the `environment` section.
526///
527/// Controls the environment variables available to sandbox processes.
528///
529/// If this section is omitted from the config, the parent environment is
530/// inherited unchanged.  If present, `clear` defaults to true and the
531/// environment is cleared before applying the configured variables.
532///
533/// # Example
534///
535/// ```lua
536/// environment = {
537///     inherit = { "TERM", "HOME" },
538///     set = {
539///         PATH = "/sbin:/bin:/usr/sbin:/usr/bin",
540///     },
541/// }
542/// ```
543#[derive(Clone, Debug)]
544pub struct Environment {
545    /// If true (default), clear the environment before setting variables.
546    /// If false, inherit the full parent environment.
547    pub clear: bool,
548    /// Variable names to copy from the parent environment (when `clear = true`).
549    pub inherit: Vec<String>,
550    /// Variables to set explicitly.
551    pub set: HashMap<String, String>,
552}
553
554impl Default for Environment {
555    fn default() -> Self {
556        Self {
557            clear: true,
558            inherit: Vec::new(),
559            set: HashMap::new(),
560        }
561    }
562}
563
564/// Sandbox configuration from the `sandboxes` section.
565///
566/// When this section is present in the configuration, builds are performed
567/// in isolated chroot environments.
568///
569/// # Example
570///
571/// ```lua
572/// sandboxes = {
573///     basedir = "/data/chroot",
574///     actions = {
575///         { action = "mount", fs = "proc", dir = "/proc" },
576///         { action = "copy", dir = "/etc" },
577///     },
578/// }
579/// ```
580#[derive(Clone, Debug, Default)]
581pub struct Sandboxes {
582    /// Base directory for sandbox roots (e.g., `/data/chroot`).
583    ///
584    /// Individual sandboxes are created as numbered subdirectories:
585    /// `basedir/0`, `basedir/1`, etc.
586    pub basedir: PathBuf,
587    /// Actions to perform during sandbox setup/teardown.
588    ///
589    /// See [`Action`] for details.
590    pub actions: Vec<Action>,
591    /// Path to bindfs binary (defaults to "bindfs").
592    pub bindfs: String,
593}
594
595impl Config {
596    /// Load configuration from a Lua file.
597    ///
598    /// # Arguments
599    ///
600    /// * `config_path` - Path to configuration file, or `None` to use `./config.lua`
601    ///
602    /// # Errors
603    ///
604    /// Returns an error if the configuration file doesn't exist or contains
605    /// invalid Lua syntax.
606    pub fn load(config_path: Option<&Path>) -> Result<Config> {
607        /*
608         * Load user-supplied configuration file, or the default location.
609         */
610        let filename = if let Some(path) = config_path {
611            path.to_path_buf()
612        } else {
613            std::env::current_dir()
614                .context("Unable to determine current directory")?
615                .join("config.lua")
616        };
617
618        /* A configuration file is mandatory. */
619        if !filename.exists() {
620            anyhow::bail!("Configuration file {} does not exist", filename.display());
621        }
622
623        /*
624         * Parse configuration file as Lua.
625         */
626        let (mut file, lua_env) =
627            load_lua(&filename)
628                .map_err(|e| anyhow!(e))
629                .with_context(|| {
630                    format!(
631                        "Unable to parse Lua configuration file {}",
632                        filename.display()
633                    )
634                })?;
635
636        /*
637         * Parse scripts section.  Paths are resolved relative to config dir
638         * if not absolute.
639         */
640        let base_dir = filename.parent().unwrap_or_else(|| Path::new("."));
641        let mut newscripts: HashMap<String, PathBuf> = HashMap::new();
642        for (k, v) in &file.scripts {
643            let fullpath = if v.is_relative() {
644                base_dir.join(v)
645            } else {
646                v.clone()
647            };
648            newscripts.insert(k.clone(), fullpath);
649        }
650        file.scripts = newscripts;
651
652        /*
653         * Validate bootstrap path exists if specified.
654         */
655        if let Some(ref bootstrap) = file.pkgsrc.bootstrap {
656            if !bootstrap.exists() {
657                anyhow::bail!(
658                    "pkgsrc.bootstrap file {} does not exist",
659                    bootstrap.display()
660                );
661            }
662        }
663
664        /*
665         * Set log_level from config file, defaulting to "info".
666         */
667        let log_level = if let Some(opts) = &file.options {
668            opts.log_level.clone().unwrap_or_else(|| "info".to_string())
669        } else {
670            "info".to_string()
671        };
672
673        Ok(Config {
674            file,
675            log_level,
676            lua_env,
677        })
678    }
679
680    pub fn build_threads(&self) -> usize {
681        if let Some(opts) = &self.file.options {
682            opts.build_threads.unwrap_or(1)
683        } else {
684            1
685        }
686    }
687
688    pub fn scan_threads(&self) -> usize {
689        if let Some(opts) = &self.file.options {
690            opts.scan_threads.unwrap_or(1)
691        } else {
692            1
693        }
694    }
695
696    pub fn strict_scan(&self) -> bool {
697        if let Some(opts) = &self.file.options {
698            opts.strict_scan.unwrap_or(false)
699        } else {
700            false
701        }
702    }
703
704    pub fn script(&self, key: &str) -> Option<&PathBuf> {
705        self.file.scripts.get(key)
706    }
707
708    pub fn make(&self) -> &PathBuf {
709        &self.file.pkgsrc.make
710    }
711
712    pub fn pkgpaths(&self) -> &Option<Vec<PkgPath>> {
713        &self.file.pkgsrc.pkgpaths
714    }
715
716    pub fn pkgsrc(&self) -> &PathBuf {
717        &self.file.pkgsrc.basedir
718    }
719
720    pub fn sandboxes(&self) -> &Option<Sandboxes> {
721        &self.file.sandboxes
722    }
723
724    pub fn environment(&self) -> Option<&Environment> {
725        self.file.environment.as_ref()
726    }
727
728    pub fn bindfs(&self) -> &str {
729        self.file
730            .sandboxes
731            .as_ref()
732            .map(|s| s.bindfs.as_str())
733            .unwrap_or("bindfs")
734    }
735
736    pub fn log_level(&self) -> &str {
737        &self.log_level
738    }
739
740    pub fn logdir(&self) -> &PathBuf {
741        &self.file.pkgsrc.logdir
742    }
743
744    pub fn save_wrkdir_patterns(&self) -> &[String] {
745        self.file.pkgsrc.save_wrkdir_patterns.as_slice()
746    }
747
748    pub fn tar(&self) -> Option<&PathBuf> {
749        self.file.pkgsrc.tar.as_ref()
750    }
751
752    pub fn build_user(&self) -> Option<&str> {
753        self.file.pkgsrc.build_user.as_deref()
754    }
755
756    pub fn bootstrap(&self) -> Option<&PathBuf> {
757        self.file.pkgsrc.bootstrap.as_ref()
758    }
759
760    /// Return list of pkgsrc variable names to cache.
761    pub fn cachevars(&self) -> &[String] {
762        self.file.pkgsrc.cachevars.as_slice()
763    }
764
765    /// Get environment variables for a package from the Lua env function/table.
766    pub fn get_pkg_env(
767        &self,
768        pkg: &ResolvedPackage,
769    ) -> Result<std::collections::HashMap<String, String>, String> {
770        self.lua_env.get_env(pkg)
771    }
772
773    /// Return environment variables for script execution.
774    ///
775    /// If `pkgsrc_env` is provided, includes the pkgsrc-derived variables
776    /// (packages, pkgtools, prefix, pkg_dbdir, pkg_refcount_dbdir).
777    /// Return environment variables for script execution.
778    ///
779    /// If `pkgsrc_env` is provided, includes the pkgsrc-derived variables
780    /// (packages, pkgtools, prefix, pkg_dbdir, pkg_refcount_dbdir) as well
781    /// as the cached variables from the `cachevars` config option.
782    pub fn script_env(&self, pkgsrc_env: Option<&PkgsrcEnv>) -> Vec<(String, String)> {
783        let mut envs = vec![
784            (
785                "bob_logdir".to_string(),
786                format!("{}", self.logdir().display()),
787            ),
788            ("bob_make".to_string(), format!("{}", self.make().display())),
789            (
790                "bob_pkgsrc".to_string(),
791                format!("{}", self.pkgsrc().display()),
792            ),
793        ];
794        if let Some(env) = pkgsrc_env {
795            envs.push((
796                "bob_packages".to_string(),
797                env.packages.display().to_string(),
798            ));
799            envs.push((
800                "bob_pkgtools".to_string(),
801                env.pkgtools.display().to_string(),
802            ));
803            envs.push(("bob_prefix".to_string(), env.prefix.display().to_string()));
804            envs.push((
805                "bob_pkg_dbdir".to_string(),
806                env.pkg_dbdir.display().to_string(),
807            ));
808            envs.push((
809                "bob_pkg_refcount_dbdir".to_string(),
810                env.pkg_refcount_dbdir.display().to_string(),
811            ));
812            for (key, value) in &env.cachevars {
813                envs.push((key.clone(), value.clone()));
814            }
815        }
816        let tar_value = self
817            .tar()
818            .map(|t| t.display().to_string())
819            .unwrap_or_else(|| "tar".to_string());
820        envs.push(("bob_tar".to_string(), tar_value));
821        if let Some(build_user) = self.build_user() {
822            envs.push(("bob_build_user".to_string(), build_user.to_string()));
823        }
824        if let Some(bootstrap) = self.bootstrap() {
825            envs.push((
826                "bob_bootstrap".to_string(),
827                format!("{}", bootstrap.display()),
828            ));
829        }
830        envs
831    }
832
833    /// Validate the configuration, checking that required paths and files exist.
834    pub fn validate(&self) -> Result<(), Vec<String>> {
835        let mut errors: Vec<String> = Vec::new();
836
837        // Check pkgsrc directory exists
838        if !self.file.pkgsrc.basedir.exists() {
839            errors.push(format!(
840                "pkgsrc basedir does not exist: {}",
841                self.file.pkgsrc.basedir.display()
842            ));
843        }
844
845        // Check make binary exists (only on host if sandboxes not enabled)
846        // When sandboxes are enabled, the make binary is inside the sandbox
847        if self.file.sandboxes.is_none() && !self.file.pkgsrc.make.exists() {
848            errors.push(format!(
849                "make binary does not exist: {}",
850                self.file.pkgsrc.make.display()
851            ));
852        }
853
854        // Check scripts exist
855        for (name, path) in &self.file.scripts {
856            if !path.exists() {
857                errors.push(format!(
858                    "Script '{}' does not exist: {}",
859                    name,
860                    path.display()
861                ));
862            } else if !path.is_file() {
863                errors.push(format!(
864                    "Script '{}' is not a file: {}",
865                    name,
866                    path.display()
867                ));
868            }
869        }
870
871        // Check sandbox basedir is writable if sandboxes enabled
872        if let Some(sandboxes) = &self.file.sandboxes {
873            // Check parent directory exists or can be created
874            if let Some(parent) = sandboxes.basedir.parent() {
875                if !parent.exists() {
876                    errors.push(format!(
877                        "Sandbox basedir parent does not exist: {}",
878                        parent.display()
879                    ));
880                }
881            }
882        }
883
884        // Check logdir can be created
885        if let Some(parent) = self.file.pkgsrc.logdir.parent() {
886            if !parent.exists() {
887                errors.push(format!(
888                    "logdir parent directory does not exist: {}",
889                    parent.display()
890                ));
891            }
892        }
893
894        if errors.is_empty() {
895            Ok(())
896        } else {
897            Err(errors)
898        }
899    }
900}
901
902/// Load a Lua configuration file and return a ConfigFile and LuaEnv.
903fn load_lua(filename: &Path) -> Result<(ConfigFile, LuaEnv), String> {
904    let lua = Lua::new();
905
906    // Add config directory to package.path so require() finds relative modules
907    if let Some(config_dir) = filename.parent() {
908        let path_setup = format!(
909            "package.path = '{}' .. '/?.lua;' .. package.path",
910            config_dir.display()
911        );
912        lua.load(&path_setup)
913            .exec()
914            .map_err(|e| format!("Failed to set package.path: {}", e))?;
915    }
916
917    // Load built-in helper functions
918    lua.load(include_str!("funcs.lua"))
919        .exec()
920        .map_err(|e| format!("Failed to load helper functions: {}", e))?;
921
922    lua.load(filename)
923        .exec()
924        .map_err(|e| format!("Lua execution error: {}", e))?;
925
926    // Get the global table (Lua script should set global variables)
927    let globals = lua.globals();
928
929    // Parse each section
930    let options =
931        parse_options(&globals).map_err(|e| format!("Error parsing options config: {}", e))?;
932    let pkgsrc_table: Table = globals
933        .get("pkgsrc")
934        .map_err(|e| format!("Error getting pkgsrc config: {}", e))?;
935    let pkgsrc =
936        parse_pkgsrc(&globals).map_err(|e| format!("Error parsing pkgsrc config: {}", e))?;
937    let scripts =
938        parse_scripts(&globals).map_err(|e| format!("Error parsing scripts config: {}", e))?;
939    let sandboxes =
940        parse_sandboxes(&globals).map_err(|e| format!("Error parsing sandboxes config: {}", e))?;
941    let environment = parse_environment(&globals)
942        .map_err(|e| format!("Error parsing environment config: {}", e))?;
943
944    // Store env function/table in registry if it exists
945    let env_key = if let Ok(env_value) = pkgsrc_table.get::<Value>("env") {
946        if !env_value.is_nil() {
947            let key = lua
948                .create_registry_value(env_value)
949                .map_err(|e| format!("Failed to store env in registry: {}", e))?;
950            Some(Arc::new(key))
951        } else {
952            None
953        }
954    } else {
955        None
956    };
957
958    let lua_env = LuaEnv {
959        lua: Arc::new(Mutex::new(lua)),
960        env_key,
961    };
962
963    let config = ConfigFile {
964        options,
965        pkgsrc,
966        scripts,
967        sandboxes,
968        environment,
969    };
970
971    Ok((config, lua_env))
972}
973
974fn parse_options(globals: &Table) -> LuaResult<Option<Options>> {
975    let options: Value = globals.get("options")?;
976    if options.is_nil() {
977        return Ok(None);
978    }
979
980    let table = options
981        .as_table()
982        .ok_or_else(|| mlua::Error::runtime("'options' must be a table"))?;
983
984    const KNOWN_KEYS: &[&str] = &["build_threads", "scan_threads", "strict_scan", "log_level"];
985    warn_unknown_keys(table, "options", KNOWN_KEYS);
986
987    Ok(Some(Options {
988        build_threads: table.get("build_threads").ok(),
989        scan_threads: table.get("scan_threads").ok(),
990        strict_scan: table.get("strict_scan").ok(),
991        log_level: table.get("log_level").ok(),
992    }))
993}
994
995/// Warn about unknown keys in a Lua table.
996fn warn_unknown_keys(table: &Table, table_name: &str, known_keys: &[&str]) {
997    for (key, _) in table.pairs::<String, Value>().flatten() {
998        if !known_keys.contains(&key.as_str()) {
999            eprintln!("Warning: unknown config key '{}.{}'", table_name, key);
1000        }
1001    }
1002}
1003
1004fn get_required_string(table: &Table, field: &str) -> LuaResult<String> {
1005    let value: Value = table.get(field)?;
1006    match value {
1007        Value::String(s) => Ok(s.to_str()?.to_string()),
1008        Value::Integer(n) => Ok(n.to_string()),
1009        Value::Number(n) => Ok(n.to_string()),
1010        Value::Nil => Err(mlua::Error::runtime(format!(
1011            "missing required field '{}'",
1012            field
1013        ))),
1014        _ => Err(mlua::Error::runtime(format!(
1015            "field '{}' must be a string, got {}",
1016            field,
1017            value.type_name()
1018        ))),
1019    }
1020}
1021
1022fn parse_pkgsrc(globals: &Table) -> LuaResult<Pkgsrc> {
1023    let pkgsrc: Table = globals.get("pkgsrc")?;
1024
1025    const KNOWN_KEYS: &[&str] = &[
1026        "basedir",
1027        "bootstrap",
1028        "build_user",
1029        "cachevars",
1030        "env",
1031        "logdir",
1032        "make",
1033        "pkgpaths",
1034        "save_wrkdir_patterns",
1035        "tar",
1036    ];
1037    warn_unknown_keys(&pkgsrc, "pkgsrc", KNOWN_KEYS);
1038
1039    let basedir = get_required_string(&pkgsrc, "basedir")?;
1040    let bootstrap: Option<PathBuf> = pkgsrc
1041        .get::<Option<String>>("bootstrap")?
1042        .map(PathBuf::from);
1043    let build_user: Option<String> = pkgsrc.get::<Option<String>>("build_user")?;
1044    let logdir = get_required_string(&pkgsrc, "logdir")?;
1045    let make = get_required_string(&pkgsrc, "make")?;
1046    let tar: Option<PathBuf> = pkgsrc.get::<Option<String>>("tar")?.map(PathBuf::from);
1047
1048    let pkgpaths: Option<Vec<PkgPath>> = match pkgsrc.get::<Value>("pkgpaths")? {
1049        Value::Nil => None,
1050        Value::Table(t) => {
1051            let paths: Vec<PkgPath> = t
1052                .sequence_values::<String>()
1053                .filter_map(|r| r.ok())
1054                .filter_map(|s| PkgPath::new(&s).ok())
1055                .collect();
1056            if paths.is_empty() { None } else { Some(paths) }
1057        }
1058        _ => None,
1059    };
1060
1061    let save_wrkdir_patterns: Vec<String> = match pkgsrc.get::<Value>("save_wrkdir_patterns")? {
1062        Value::Nil => Vec::new(),
1063        Value::Table(t) => t
1064            .sequence_values::<String>()
1065            .filter_map(|r| r.ok())
1066            .collect(),
1067        _ => Vec::new(),
1068    };
1069
1070    let cachevars: Vec<String> = match pkgsrc.get::<Value>("cachevars")? {
1071        Value::Nil => Vec::new(),
1072        Value::Table(t) => t
1073            .sequence_values::<String>()
1074            .filter_map(|r| r.ok())
1075            .collect(),
1076        _ => Vec::new(),
1077    };
1078
1079    Ok(Pkgsrc {
1080        basedir: PathBuf::from(basedir),
1081        bootstrap,
1082        build_user,
1083        cachevars,
1084        logdir: PathBuf::from(logdir),
1085        make: PathBuf::from(make),
1086        pkgpaths,
1087        save_wrkdir_patterns,
1088        tar,
1089    })
1090}
1091
1092fn parse_scripts(globals: &Table) -> LuaResult<HashMap<String, PathBuf>> {
1093    let scripts: Value = globals.get("scripts")?;
1094    if scripts.is_nil() {
1095        return Ok(HashMap::new());
1096    }
1097
1098    let table = scripts
1099        .as_table()
1100        .ok_or_else(|| mlua::Error::runtime("'scripts' must be a table"))?;
1101
1102    let mut result = HashMap::new();
1103    for pair in table.pairs::<String, String>() {
1104        let (k, v) = pair?;
1105        result.insert(k, PathBuf::from(v));
1106    }
1107
1108    Ok(result)
1109}
1110
1111fn parse_sandboxes(globals: &Table) -> LuaResult<Option<Sandboxes>> {
1112    let sandboxes: Value = globals.get("sandboxes")?;
1113    if sandboxes.is_nil() {
1114        return Ok(None);
1115    }
1116
1117    let table = sandboxes
1118        .as_table()
1119        .ok_or_else(|| mlua::Error::runtime("'sandboxes' must be a table"))?;
1120
1121    const KNOWN_KEYS: &[&str] = &["actions", "basedir", "bindfs"];
1122    warn_unknown_keys(table, "sandboxes", KNOWN_KEYS);
1123
1124    let basedir: String = table.get("basedir")?;
1125    let bindfs: String = table
1126        .get::<Option<String>>("bindfs")?
1127        .unwrap_or_else(|| String::from("bindfs"));
1128
1129    let actions_value: Value = table.get("actions")?;
1130    let actions = if actions_value.is_nil() {
1131        Vec::new()
1132    } else {
1133        let actions_table = actions_value
1134            .as_table()
1135            .ok_or_else(|| mlua::Error::runtime("'sandboxes.actions' must be a table"))?;
1136        parse_actions(actions_table, globals)?
1137    };
1138
1139    Ok(Some(Sandboxes {
1140        basedir: PathBuf::from(basedir),
1141        actions,
1142        bindfs,
1143    }))
1144}
1145
1146fn parse_actions(table: &Table, globals: &Table) -> LuaResult<Vec<Action>> {
1147    let mut actions = Vec::new();
1148    for v in table.sequence_values::<Table>() {
1149        let mut action = Action::from_lua(&v?)?;
1150        if let Some(varpath) = action.ifset().map(String::from) {
1151            match resolve_lua_var(globals, &varpath) {
1152                Some(val) => action.substitute_var(&varpath, &val),
1153                None => continue,
1154            }
1155        }
1156        actions.push(action);
1157    }
1158    Ok(actions)
1159}
1160
1161/**
1162 * Resolve a dotted variable path (e.g. "pkgsrc.build_user") by
1163 * walking the Lua globals table.
1164 */
1165fn resolve_lua_var(globals: &Table, path: &str) -> Option<String> {
1166    let mut parts = path.split('.');
1167    let first = parts.next()?;
1168    let mut current: Value = globals.get(first).ok()?;
1169    for key in parts {
1170        match current {
1171            Value::Table(t) => {
1172                current = t.get(key).ok()?;
1173            }
1174            _ => return None,
1175        }
1176    }
1177    match current {
1178        Value::String(s) => Some(s.to_str().ok()?.to_string()),
1179        Value::Integer(n) => Some(n.to_string()),
1180        Value::Number(n) => Some(n.to_string()),
1181        _ => None,
1182    }
1183}
1184
1185fn parse_environment(globals: &Table) -> LuaResult<Option<Environment>> {
1186    let environment: Value = globals.get("environment")?;
1187    if environment.is_nil() {
1188        return Ok(None);
1189    }
1190
1191    let table = environment
1192        .as_table()
1193        .ok_or_else(|| mlua::Error::runtime("'environment' must be a table"))?;
1194
1195    const KNOWN_KEYS: &[&str] = &["clear", "inherit", "set"];
1196    warn_unknown_keys(table, "environment", KNOWN_KEYS);
1197
1198    let clear: bool = table.get::<Option<bool>>("clear")?.unwrap_or(true);
1199
1200    let inherit: Vec<String> = match table.get::<Value>("inherit")? {
1201        Value::Nil => Vec::new(),
1202        Value::Table(t) => t
1203            .sequence_values::<String>()
1204            .filter_map(|r| r.ok())
1205            .collect(),
1206        _ => {
1207            return Err(mlua::Error::runtime(
1208                "'environment.inherit' must be a table",
1209            ));
1210        }
1211    };
1212
1213    let set: HashMap<String, String> = match table.get::<Value>("set")? {
1214        Value::Nil => HashMap::new(),
1215        Value::Table(t) => {
1216            let mut map = HashMap::new();
1217            for pair in t.pairs::<String, String>() {
1218                let (k, v) = pair?;
1219                map.insert(k, v);
1220            }
1221            map
1222        }
1223        _ => return Err(mlua::Error::runtime("'environment.set' must be a table")),
1224    };
1225
1226    Ok(Some(Environment {
1227        clear,
1228        inherit,
1229        set,
1230    }))
1231}