Skip to main content

treeboot_core/
runtime.rs

1use crate::{ActionPlanOptions, ConfigRuntimeOptions, EnvironmentInput, Error, Result};
2
3/// Environment overrides for config runtime options.
4#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
5pub struct RuntimeOptionOverrides {
6    /// Strict mode environment override.
7    pub strict: Option<bool>,
8    /// Source-boundary environment override.
9    pub dangerously_allow_sources_outside_root: Option<bool>,
10    /// Target-boundary environment override.
11    pub dangerously_allow_targets_outside_worktree: Option<bool>,
12}
13
14impl RuntimeOptionOverrides {
15    /// Parses treeboot runtime option overrides from explicit environment input.
16    ///
17    /// # Errors
18    ///
19    /// Returns an error when an environment value is not a supported boolean.
20    pub fn from_environment(environment: &EnvironmentInput) -> Result<Self> {
21        Ok(Self {
22            strict: env_bool("TREEBOOT_STRICT", environment.treeboot_strict.as_deref())?,
23            dangerously_allow_sources_outside_root: env_bool(
24                "TREEBOOT_DANGEROUSLY_ALLOW_SOURCES_OUTSIDE_ROOT",
25                environment
26                    .treeboot_dangerously_allow_sources_outside_root
27                    .as_deref(),
28            )?,
29            dangerously_allow_targets_outside_worktree: env_bool(
30                "TREEBOOT_DANGEROUSLY_ALLOW_TARGETS_OUTSIDE_WORKTREE",
31                environment
32                    .treeboot_dangerously_allow_targets_outside_worktree
33                    .as_deref(),
34            )?,
35        })
36    }
37
38    /// Reads treeboot runtime option overrides from the process environment.
39    ///
40    /// # Errors
41    ///
42    /// Returns an error when an environment value is not a supported boolean.
43    pub fn from_process_env() -> Result<Self> {
44        Self::from_environment(&EnvironmentInput::from_process_env())
45    }
46}
47
48/// Runtime policy resolved from environment and CLI input.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct RuntimePolicy {
51    overrides: RuntimeOptionOverrides,
52    cli_strict: bool,
53}
54
55impl RuntimePolicy {
56    /// Parses runtime policy from explicit environment input and CLI strictness.
57    ///
58    /// # Errors
59    ///
60    /// Returns an error when an environment value is not a supported boolean.
61    pub fn from_environment(environment: &EnvironmentInput, cli_strict: bool) -> Result<Self> {
62        Ok(Self::from_overrides(
63            RuntimeOptionOverrides::from_environment(environment)?,
64            cli_strict,
65        ))
66    }
67
68    /// Reads runtime policy from the process environment and CLI strictness.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error when an environment value is not a supported boolean.
73    pub fn from_process_env(cli_strict: bool) -> Result<Self> {
74        Ok(Self::from_overrides(
75            RuntimeOptionOverrides::from_process_env()?,
76            cli_strict,
77        ))
78    }
79
80    /// Builds runtime policy from parsed environment overrides.
81    #[must_use]
82    pub const fn from_overrides(overrides: RuntimeOptionOverrides, cli_strict: bool) -> Self {
83        Self {
84            overrides,
85            cli_strict,
86        }
87    }
88
89    /// Returns strict mode before config discovery.
90    #[must_use]
91    pub const fn pre_config_strict(&self) -> bool {
92        self.cli_strict || matches!(self.overrides.strict, Some(true))
93    }
94
95    /// Resolves runtime options using defaults, config, environment, then CLI.
96    #[must_use]
97    pub fn resolve(&self, config: &ConfigRuntimeOptions) -> ResolvedRuntimePolicy {
98        let mut options = config.clone();
99
100        if let Some(strict) = self.overrides.strict {
101            options.strict = strict;
102        }
103        if let Some(allow) = self.overrides.dangerously_allow_sources_outside_root {
104            options.dangerously_allow_sources_outside_root = allow;
105        }
106        if let Some(allow) = self.overrides.dangerously_allow_targets_outside_worktree {
107            options.dangerously_allow_targets_outside_worktree = allow;
108        }
109        if self.cli_strict {
110            options.strict = true;
111        }
112
113        ResolvedRuntimePolicy { options }
114    }
115}
116
117/// Runtime policy after config defaults, environment, and CLI are merged.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct ResolvedRuntimePolicy {
120    options: ConfigRuntimeOptions,
121}
122
123impl ResolvedRuntimePolicy {
124    /// Returns the resolved config-compatible runtime options.
125    #[must_use]
126    pub const fn options(&self) -> &ConfigRuntimeOptions {
127        &self.options
128    }
129
130    /// Consumes the policy and returns the resolved config-compatible options.
131    #[must_use]
132    pub fn into_options(self) -> ConfigRuntimeOptions {
133        self.options
134    }
135
136    /// Returns whether strict mode is enabled.
137    #[must_use]
138    pub const fn strict(&self) -> bool {
139        self.options.strict
140    }
141
142    /// Returns default ignore patterns from the resolved policy.
143    #[must_use]
144    pub fn default_ignore(&self) -> &[String] {
145        &self.options.default_ignore
146    }
147
148    /// Returns action-plan validation options for this resolved policy.
149    #[must_use]
150    pub fn action_plan_options(&self) -> ActionPlanOptions {
151        ActionPlanOptions::from(self.options.clone())
152    }
153
154    /// Consumes the policy and returns action-plan validation options.
155    #[must_use]
156    pub fn into_action_plan_options(self) -> ActionPlanOptions {
157        ActionPlanOptions::from(self.options)
158    }
159}
160
161fn env_bool(name: &'static str, value: Option<&std::ffi::OsStr>) -> Result<Option<bool>> {
162    let Some(value) = value else {
163        return Ok(None);
164    };
165
166    let Some(value) = value.to_str() else {
167        return Err(Error::InvalidBooleanEnv {
168            name,
169            value: value.to_string_lossy().into_owned(),
170        });
171    };
172
173    parse_bool(value)
174        .ok_or_else(|| Error::InvalidBooleanEnv {
175            name,
176            value: value.to_owned(),
177        })
178        .map(Some)
179}
180
181fn parse_bool(value: &str) -> Option<bool> {
182    match value.to_ascii_lowercase().as_str() {
183        "1" | "true" | "yes" | "on" => Some(true),
184        "0" | "false" | "no" | "off" => Some(false),
185        _ => None,
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use std::ffi::OsString;
192
193    use super::*;
194
195    #[test]
196    fn runtime_option_overrides_should_parse_explicit_environment_input() {
197        let overrides = RuntimeOptionOverrides::from_environment(&EnvironmentInput {
198            treeboot_strict: Some(OsString::from("yes")),
199            treeboot_dangerously_allow_sources_outside_root: Some(OsString::from("true")),
200            treeboot_dangerously_allow_targets_outside_worktree: Some(OsString::from("0")),
201            ..EnvironmentInput::empty()
202        })
203        .expect("environment should parse");
204
205        assert_eq!(
206            overrides,
207            RuntimeOptionOverrides {
208                strict: Some(true),
209                dangerously_allow_sources_outside_root: Some(true),
210                dangerously_allow_targets_outside_worktree: Some(false),
211            }
212        );
213    }
214
215    #[test]
216    fn runtime_option_overrides_should_reject_invalid_explicit_environment_input() {
217        let error = RuntimeOptionOverrides::from_environment(&EnvironmentInput {
218            treeboot_strict: Some(OsString::from("sometimes")),
219            ..EnvironmentInput::empty()
220        })
221        .expect_err("environment should fail");
222
223        assert!(matches!(
224            error,
225            Error::InvalidBooleanEnv {
226                name: "TREEBOOT_STRICT",
227                ..
228            }
229        ));
230    }
231
232    #[cfg(unix)]
233    #[test]
234    fn runtime_option_overrides_should_reject_non_utf8_explicit_environment_input() {
235        use std::os::unix::ffi::OsStringExt;
236
237        let error = RuntimeOptionOverrides::from_environment(&EnvironmentInput {
238            treeboot_strict: Some(OsString::from_vec(vec![0xFF])),
239            ..EnvironmentInput::empty()
240        })
241        .expect_err("environment should fail");
242
243        assert!(matches!(
244            error,
245            Error::InvalidBooleanEnv {
246                name: "TREEBOOT_STRICT",
247                ..
248            }
249        ));
250    }
251
252    #[test]
253    fn runtime_policy_should_resolve_config_defaults() {
254        let policy = RuntimePolicy::from_overrides(RuntimeOptionOverrides::default(), false);
255        let config = ConfigRuntimeOptions {
256            strict: true,
257            default_ignore: vec![".DS_Store".to_owned()],
258            dangerously_allow_sources_outside_root: true,
259            dangerously_allow_targets_outside_worktree: false,
260        };
261
262        let resolved = policy.resolve(&config);
263
264        assert_eq!(resolved.options(), &config);
265    }
266
267    #[test]
268    fn runtime_policy_should_apply_environment_overrides_after_config() {
269        let policy = RuntimePolicy::from_overrides(
270            RuntimeOptionOverrides {
271                strict: Some(false),
272                dangerously_allow_sources_outside_root: Some(false),
273                dangerously_allow_targets_outside_worktree: Some(true),
274            },
275            false,
276        );
277        let config = ConfigRuntimeOptions {
278            strict: true,
279            default_ignore: vec!["build".to_owned()],
280            dangerously_allow_sources_outside_root: true,
281            dangerously_allow_targets_outside_worktree: false,
282        };
283
284        let resolved = policy.resolve(&config);
285
286        assert_eq!(
287            resolved.into_options(),
288            ConfigRuntimeOptions {
289                strict: false,
290                default_ignore: vec!["build".to_owned()],
291                dangerously_allow_sources_outside_root: false,
292                dangerously_allow_targets_outside_worktree: true,
293            }
294        );
295    }
296
297    #[test]
298    fn runtime_policy_should_apply_cli_strict_after_environment() {
299        let policy = RuntimePolicy::from_overrides(
300            RuntimeOptionOverrides {
301                strict: Some(false),
302                ..RuntimeOptionOverrides::default()
303            },
304            true,
305        );
306
307        let resolved = policy.resolve(&ConfigRuntimeOptions::default());
308
309        assert!(resolved.strict());
310    }
311
312    #[test]
313    fn resolved_runtime_policy_should_convert_into_action_plan_options() {
314        let policy = RuntimePolicy::from_overrides(
315            RuntimeOptionOverrides {
316                strict: Some(true),
317                dangerously_allow_sources_outside_root: Some(true),
318                dangerously_allow_targets_outside_worktree: Some(true),
319            },
320            false,
321        );
322
323        let options = policy
324            .resolve(&ConfigRuntimeOptions {
325                default_ignore: vec!["target".to_owned()],
326                ..ConfigRuntimeOptions::default()
327            })
328            .into_action_plan_options();
329
330        assert_eq!(
331            options,
332            ActionPlanOptions {
333                strict: true,
334                dangerously_allow_sources_outside_root: true,
335                dangerously_allow_targets_outside_worktree: true,
336            }
337        );
338    }
339
340    #[test]
341    fn runtime_policy_should_enable_pre_config_strict_from_cli_or_environment() {
342        let env_policy = RuntimePolicy::from_overrides(
343            RuntimeOptionOverrides {
344                strict: Some(true),
345                ..RuntimeOptionOverrides::default()
346            },
347            false,
348        );
349        let cli_policy = RuntimePolicy::from_overrides(RuntimeOptionOverrides::default(), true);
350
351        assert!(env_policy.pre_config_strict());
352        assert!(cli_policy.pre_config_strict());
353    }
354
355    #[test]
356    fn parse_bool_should_accept_supported_true_values() {
357        for value in ["1", "true", "TRUE", "yes", "on"] {
358            assert_eq!(parse_bool(value), Some(true), "value {value:?}");
359        }
360    }
361
362    #[test]
363    fn parse_bool_should_accept_supported_false_values() {
364        for value in ["0", "false", "FALSE", "no", "off"] {
365            assert_eq!(parse_bool(value), Some(false), "value {value:?}");
366        }
367    }
368
369    #[test]
370    fn parse_bool_should_reject_unsupported_values() {
371        assert_eq!(parse_bool("sometimes"), None);
372    }
373}