Skip to main content

cabin_core/
build_flags.rs

1//! Typed semantic build flags.
2//!
3//! Cabin recognizes explicit, semantic build flags that compose
4//! across four layers, in this order (later layers override or
5//! append to earlier ones):
6//!
7//! 1. Built-in backend defaults (today: the planner adds
8//!    `-std=c11` for C compiles and `-std=c++17` for C++
9//!    compiles).
10//! 2. Per-package general `[profile]` flags from the manifest.
11//! 3. Per-package matching `[target.'cfg(...)'.profile]` flags.
12//! 4. Workspace-root `[profile.<name>]` flags for the selected
13//!    profile.
14//!
15//! Manifest-declared fields are intentionally explicit: defines,
16//! include directories, C-only compile arguments, C++-only compile
17//! arguments, and link arguments. The C/C++ argv spaces stay
18//! separate all the way to the planner.
19
20use std::collections::BTreeSet;
21use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26use crate::condition::Condition;
27
28/// Manifest-shape build-flag declaration. One per `[profile]` /
29/// `[target.'cfg(...)'.profile]` / `[profile.<name>]` table.
30///
31/// Every field is optional so omission means "no contribution at
32/// this layer". The TOML parser rejects unknown fields explicitly
33/// so a future field cannot silently slip through.
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
35pub struct ProfileFlags {
36    /// Preprocessor macro definitions, one per entry. Each value
37    /// is either `"NAME"` (defines without a value) or
38    /// `"NAME=value"` (defines with an explicit value). Names are
39    /// validated at parse time; the planner emits `-DNAME` /
40    /// `-DNAME=value` directly.
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub defines: Vec<String>,
43    /// Additional include directories. Paths are validated at
44    /// parse time: absolute paths and any path containing a `..`
45    /// component are rejected so include-search can never escape
46    /// a published source archive.
47    #[serde(
48        default,
49        rename = "include-dirs",
50        skip_serializing_if = "Vec::is_empty"
51    )]
52    pub include_dirs: Vec<PathBuf>,
53    /// Escape-hatch list of arguments appended verbatim to every
54    /// **C** compile command this layer applies to. Use this for
55    /// flags that are valid only when compiling C translation
56    /// units (e.g. `-std=c99`). Empty by default.
57    #[serde(default, rename = "cflags", skip_serializing_if = "Vec::is_empty")]
58    pub cflags: Vec<String>,
59    /// Escape-hatch list of arguments appended verbatim to every
60    /// **C++** compile command this layer applies to. Use this
61    /// for flags that are valid only when compiling C++
62    /// translation units (e.g. `-fno-rtti`, `-std=c++20`). Empty
63    /// by default.
64    #[serde(default, rename = "cxxflags", skip_serializing_if = "Vec::is_empty")]
65    pub cxxflags: Vec<String>,
66    /// Escape-hatch list of arguments appended verbatim to every
67    /// link command this layer applies to.
68    #[serde(default, rename = "ldflags", skip_serializing_if = "Vec::is_empty")]
69    pub ldflags: Vec<String>,
70}
71
72impl ProfileFlags {
73    pub fn is_empty(&self) -> bool {
74        self.defines.is_empty()
75            && self.include_dirs.is_empty()
76            && self.cflags.is_empty()
77            && self.cxxflags.is_empty()
78            && self.ldflags.is_empty()
79    }
80
81    /// Run the validation rules that apply at manifest parse time.
82    ///
83    /// - Defines must be non-empty and must not start with `=`.
84    /// - Include directories must be relative and must not contain
85    ///   any `..` component.
86    ///
87    /// # Errors
88    /// Returns [`BuildFlagsValidationError::EmptyDefine`] for an empty define,
89    /// [`BuildFlagsValidationError::DefineMissingName`] for a define starting
90    /// with `=`, and propagates any error from validating an include directory
91    /// (a non-relative directory or one containing a `..` component).
92    pub fn validate(&self) -> Result<(), BuildFlagsValidationError> {
93        for define in &self.defines {
94            if define.is_empty() {
95                return Err(BuildFlagsValidationError::EmptyDefine);
96            }
97            if define.starts_with('=') {
98                return Err(BuildFlagsValidationError::DefineMissingName {
99                    raw: define.clone(),
100                });
101            }
102        }
103        for dir in &self.include_dirs {
104            validate_include_dir(dir)?;
105        }
106        Ok(())
107    }
108}
109
110/// Conditional `[target.'cfg(...)'.profile]` block. Same shape as
111/// [`ProfileFlags`] but tagged with the predicate that gates it.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct ConditionalProfileFlags {
114    pub condition: Condition,
115    #[serde(flatten, default, skip_serializing_if = "ProfileFlags::is_empty")]
116    pub flags: ProfileFlags,
117}
118
119/// Per-package build-flags settings. Holds the unconditional
120/// `[profile]` table plus any `[target.'cfg(...)'.profile]`
121/// overrides declared in the same manifest.
122#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
123pub struct ProfileSettings {
124    #[serde(default, skip_serializing_if = "ProfileFlags::is_empty")]
125    pub general: ProfileFlags,
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub conditional: Vec<ConditionalProfileFlags>,
128}
129
130impl ProfileSettings {
131    pub fn is_empty(&self) -> bool {
132        self.general.is_empty() && self.conditional.is_empty()
133    }
134}
135
136/// Final, deterministic build-flag set fed to the planner.
137///
138/// `defines` is sorted-and-deduplicated (defines are commutative
139/// for our purposes); include and argv lists keep user-visible
140/// order, with first-occurrence dedup for include dirs to mirror
141/// the existing planner behavior.
142#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
143pub struct ResolvedProfileFlags {
144    pub defines: Vec<String>,
145    pub include_dirs: Vec<PathBuf>,
146    /// Language-neutral compile-time escape-hatch arguments.
147    /// Applied to every compile command, both C/C++.
148    pub extra_compile_args: Vec<String>,
149    /// C-only compile-time escape-hatch arguments. Applied only
150    /// when the compile command produces an object from a `.c`
151    /// translation unit.
152    pub cflags: Vec<String>,
153    /// C++-only compile-time escape-hatch arguments. Applied only
154    /// when the compile command produces an object from a C++
155    /// translation unit (`.cc` / `.cpp` / `.cxx` / `.c++` /
156    /// `.C`).
157    pub cxxflags: Vec<String>,
158    pub ldflags: Vec<String>,
159}
160
161impl ResolvedProfileFlags {
162    pub fn is_empty(&self) -> bool {
163        self.defines.is_empty()
164            && self.include_dirs.is_empty()
165            && self.extra_compile_args.is_empty()
166            && self.cflags.is_empty()
167            && self.cxxflags.is_empty()
168            && self.ldflags.is_empty()
169    }
170
171    /// Compact JSON view used by `cabin metadata`.
172    pub fn as_json(&self) -> serde_json::Value {
173        serde_json::json!({
174            "defines": self.defines,
175            "include_dirs": self
176                .include_dirs
177                .iter()
178                .map(|p| p.display().to_string())
179                .collect::<Vec<_>>(),
180            "extra_compile_args": self.extra_compile_args,
181            "cflags": self.cflags,
182            "cxxflags": self.cxxflags,
183            "ldflags": self.ldflags,
184        })
185    }
186}
187
188/// Resolve build flags by merging the per-package and
189/// per-profile layers, in order.
190///
191/// `package` is the package's own `[profile]` /
192/// `[target.'cfg(...)'.profile]` settings. `profile` is the
193/// **already-merged-across-inherits-chain** per-profile
194/// `ProfileFlags` produced by
195/// [`crate::profile::resolve_profile`] — *not* the lone overlay
196/// from the selected profile's `[profile.<name>]` table. The
197/// inherits-chain merge has already happened upstream via
198/// `ProfileFlags::append_layer`, so this layer simply lands
199/// on top of `package.general` and the matching conditional
200/// flags. `host_platform` is what the conditional layer
201/// evaluates against — passing the same `TargetPlatform` Cabin
202/// uses elsewhere keeps the cfg semantics consistent with
203/// target dependencies.
204///
205/// `package_trusted` says whether `package` comes from code the
206/// user controls — the workspace root, a member, or a `path`
207/// dependency. When it is `false` (a registry / downloaded
208/// dependency) the `cflags` / `cxxflags` / `ldflags` arrays that
209/// `package` declares for its own sources are dropped before the
210/// trusted `profile` layer is applied: those arrays are
211/// unvalidated and a `-fplugin=` / `-B<dir>` / `-specs=` /
212/// `-Xclang -load` / `-fuse-ld=<path>` entry would make the
213/// compiler or linker execute attacker-supplied code at build
214/// time. `defines` / `include_dirs` are validated at parse time
215/// (see [`ProfileFlags::validate`]) and are kept regardless of
216/// trust, and the `profile` layer — the trusted, root-derived
217/// flags — always applies so an untrusted dependency still builds
218/// with the user's selected profile.
219pub fn resolve_build_flags(
220    package: &ProfileSettings,
221    profile: Option<&ProfileFlags>,
222    host_platform: &crate::condition::TargetPlatform,
223    package_trusted: bool,
224) -> ResolvedProfileFlags {
225    let mut out = ResolvedProfileFlags::default();
226
227    apply_layer(&mut out, &package.general);
228    for conditional in &package.conditional {
229        if conditional.condition.evaluate(host_platform) {
230            apply_layer(&mut out, &conditional.flags);
231        }
232    }
233    if !package_trusted {
234        // Untrusted (registry) dependency: discard the compiler /
235        // linker flag arrays it declared for its own sources. These
236        // are unvalidated, so a `-fplugin=` / `-B<dir>` / `-specs=`
237        // / `-Xclang -load` entry would run attacker code inside the
238        // compiler or linker during `cabin build`. `defines` /
239        // `include_dirs` are validated elsewhere and stay; the
240        // trusted `profile` layer below still applies.
241        out.cflags.clear();
242        out.cxxflags.clear();
243        out.ldflags.clear();
244    }
245    if let Some(prof) = profile {
246        apply_layer(&mut out, prof);
247    }
248
249    finalize(&mut out);
250    out
251}
252
253/// Append every field of a [`ProfileFlags`] layer into a target
254/// whose fields are structurally identical to `ProfileFlags` —
255/// either a [`ProfileFlags`] accumulator (used by the
256/// inherits-chain merge in
257/// [`crate::profile::resolve_profile`]) or a
258/// [`ResolvedProfileFlags`] accumulator (used by
259/// [`resolve_build_flags`]'s package / conditional / profile
260/// layer chain).
261///
262/// One canonical field list lives here so a future array field
263/// added to [`ProfileFlags`] needs exactly one update site.
264/// Both [`ProfileFlags::append_layer`] and [`apply_layer`]
265/// delegate to this macro; do not duplicate the per-field walk
266/// elsewhere.
267macro_rules! append_profile_flag_layer {
268    ($target:expr, $layer:expr) => {{
269        let target = $target;
270        let layer = $layer;
271        // `defines` are appended verbatim here. `resolve_build_flags`'s
272        // `finalize` step sort-and-dedups them once at the end, so a
273        // second normalization path inside the per-layer append would
274        // be a double-pass on the resolved side and break the
275        // semantics expected by the inherits-chain merge accumulator,
276        // which is itself a layer for that same finalize step.
277        target.defines.extend(layer.defines.iter().cloned());
278        for inc in &layer.include_dirs {
279            if !target.include_dirs.iter().any(|existing| existing == inc) {
280                target.include_dirs.push(inc.clone());
281            }
282        }
283        target.cflags.extend(layer.cflags.iter().cloned());
284        target.cxxflags.extend(layer.cxxflags.iter().cloned());
285        target.ldflags.extend(layer.ldflags.iter().cloned());
286    }};
287}
288
289impl ProfileFlags {
290    /// Append every field of `layer` into `self`, using the
291    /// same per-field semantics as the package / conditional /
292    /// profile layer chain in [`resolve_build_flags`].
293    ///
294    /// The merged accumulator is what
295    /// [`crate::profile::resolve_profile`] builds when it walks
296    /// a custom profile's `inherits` chain root → selected.
297    /// Consumers downstream feed the resulting `ProfileFlags`
298    /// to [`resolve_build_flags`] as the `profile` parameter,
299    /// where it lands on top of the per-package general /
300    /// conditional layers via the same macro.
301    pub(crate) fn append_layer(&mut self, layer: &ProfileFlags) {
302        append_profile_flag_layer!(self, layer);
303    }
304}
305
306fn apply_layer(target: &mut ResolvedProfileFlags, layer: &ProfileFlags) {
307    append_profile_flag_layer!(target, layer);
308}
309
310fn finalize(target: &mut ResolvedProfileFlags) {
311    // Defines are commutative: `-DA -DB` and `-DB -DA` produce the
312    // same preprocessor state, so a stable sort + dedup gives us a
313    // deterministic shape that does not depend on declaration
314    // order across layers.
315    let dedup: BTreeSet<String> = target.defines.drain(..).collect();
316    target.defines = dedup.into_iter().collect();
317    // Include dirs already deduplicated by `apply_layer` while
318    // preserving first-seen order; nothing more to do here.
319    // Argument lists preserve user order; no sorting.
320}
321
322/// Errors produced while validating a manifest-side build-flags
323/// declaration.
324#[derive(Debug, Error, Clone, PartialEq, Eq)]
325pub enum BuildFlagsValidationError {
326    #[error("[profile] declares an empty define entry")]
327    EmptyDefine,
328    #[error("[profile] define entry {raw:?} is missing a name")]
329    DefineMissingName { raw: String },
330    #[error(
331        "[profile] include directory {path:?} must be a relative path; absolute paths are not allowed"
332    )]
333    AbsoluteIncludeDir { path: String },
334    #[error(
335        "[profile] include directory {path:?} must not contain `..`; include search paths cannot escape the package root"
336    )]
337    IncludeDirHasParent { path: String },
338    #[error("[profile] include directory {path:?} contains a non-UTF-8 component")]
339    NonUtf8IncludeDir { path: String },
340}
341
342fn validate_include_dir(dir: &Path) -> Result<(), BuildFlagsValidationError> {
343    if dir.is_absolute() {
344        return Err(BuildFlagsValidationError::AbsoluteIncludeDir {
345            path: display_path(dir),
346        });
347    }
348    for component in dir.components() {
349        match component {
350            std::path::Component::ParentDir => {
351                return Err(BuildFlagsValidationError::IncludeDirHasParent {
352                    path: display_path(dir),
353                });
354            }
355            std::path::Component::Prefix(_) | std::path::Component::RootDir => {
356                return Err(BuildFlagsValidationError::AbsoluteIncludeDir {
357                    path: display_path(dir),
358                });
359            }
360            std::path::Component::Normal(part) => {
361                if part.to_str().is_none() {
362                    return Err(BuildFlagsValidationError::NonUtf8IncludeDir {
363                        path: display_path(dir),
364                    });
365                }
366            }
367            std::path::Component::CurDir => {}
368        }
369    }
370    Ok(())
371}
372
373fn display_path(dir: &Path) -> String {
374    dir.display().to_string()
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::condition::{ConditionKey, TargetPlatform};
381
382    fn host_for(os: &str) -> TargetPlatform {
383        let mut p = TargetPlatform::current();
384        p.os = os.to_owned();
385        p
386    }
387
388    #[test]
389    fn empty_settings_resolve_to_empty_flags() {
390        let p = ProfileSettings::default();
391        let r = resolve_build_flags(&p, None, &host_for("linux"), true);
392        assert!(r.is_empty());
393    }
394
395    #[test]
396    fn defines_merge_dedup_and_sort() {
397        let mut p = ProfileSettings::default();
398        p.general.defines = vec!["B".into(), "A".into(), "B".into()];
399        let r = resolve_build_flags(&p, None, &host_for("linux"), true);
400        assert_eq!(r.defines, vec!["A".to_owned(), "B".to_owned()]);
401    }
402
403    #[test]
404    fn include_dirs_keep_first_occurrence_order() {
405        let mut p = ProfileSettings::default();
406        p.general.include_dirs = vec![
407            PathBuf::from("include"),
408            PathBuf::from("third_party/include"),
409            PathBuf::from("include"),
410        ];
411        let r = resolve_build_flags(&p, None, &host_for("linux"), true);
412        assert_eq!(
413            r.include_dirs,
414            vec![
415                PathBuf::from("include"),
416                PathBuf::from("third_party/include"),
417            ]
418        );
419    }
420
421    #[test]
422    fn matching_conditional_layer_is_applied() {
423        let mut p = ProfileSettings::default();
424        p.general.defines = vec!["BASE".into()];
425        p.conditional.push(ConditionalProfileFlags {
426            condition: Condition::KeyValue {
427                key: ConditionKey::Os,
428                value: "linux".into(),
429            },
430            flags: ProfileFlags {
431                defines: vec!["LINUX_ONLY".into()],
432                ..Default::default()
433            },
434        });
435        let r = resolve_build_flags(&p, None, &host_for("linux"), true);
436        assert_eq!(r.defines, vec!["BASE".to_owned(), "LINUX_ONLY".to_owned()]);
437    }
438
439    #[test]
440    fn non_matching_conditional_layer_is_skipped() {
441        let mut p = ProfileSettings::default();
442        p.general.defines = vec!["BASE".into()];
443        p.conditional.push(ConditionalProfileFlags {
444            condition: Condition::KeyValue {
445                key: ConditionKey::Os,
446                value: "macos".into(),
447            },
448            flags: ProfileFlags {
449                defines: vec!["MAC_ONLY".into()],
450                ..Default::default()
451            },
452        });
453        let r = resolve_build_flags(&p, None, &host_for("linux"), true);
454        assert_eq!(r.defines, vec!["BASE".to_owned()]);
455    }
456
457    #[test]
458    fn profile_layer_appends_after_target_conditional() {
459        let mut p = ProfileSettings::default();
460        p.general.cxxflags = vec!["-fPIC".into()];
461        p.conditional.push(ConditionalProfileFlags {
462            condition: Condition::KeyValue {
463                key: ConditionKey::Os,
464                value: "linux".into(),
465            },
466            flags: ProfileFlags {
467                cxxflags: vec!["-flto=thin".into()],
468                ..Default::default()
469            },
470        });
471        let prof = ProfileFlags {
472            cxxflags: vec!["-Wall".into()],
473            ..Default::default()
474        };
475        let r = resolve_build_flags(&p, Some(&prof), &host_for("linux"), true);
476        assert_eq!(
477            r.cxxflags,
478            vec![
479                "-fPIC".to_owned(),
480                "-flto=thin".to_owned(),
481                "-Wall".to_owned(),
482            ]
483        );
484    }
485
486    #[test]
487    fn untrusted_package_drops_command_flags_but_keeps_defines_and_includes() {
488        let mut p = ProfileSettings::default();
489        p.general.defines = vec!["DEP_DEFINE".into()];
490        p.general.include_dirs = vec![PathBuf::from("dep/include")];
491        p.general.cflags = vec!["-fplugin=evil.so".into()];
492        p.general.cxxflags = vec!["-Xclang".into(), "-load".into()];
493        p.general.ldflags = vec!["-fuse-ld=/tmp/evil".into()];
494        // A matching conditional layer must not be able to sneak flags past
495        // the drop either.
496        p.conditional.push(ConditionalProfileFlags {
497            condition: Condition::KeyValue {
498                key: ConditionKey::Os,
499                value: "linux".into(),
500            },
501            flags: ProfileFlags {
502                cxxflags: vec!["-B.".into()],
503                ldflags: vec!["-specs=evil.specs".into()],
504                ..Default::default()
505            },
506        });
507
508        let untrusted = resolve_build_flags(&p, None, &host_for("linux"), false);
509        assert!(
510            untrusted.cflags.is_empty(),
511            "untrusted cflags must be dropped"
512        );
513        assert!(
514            untrusted.cxxflags.is_empty(),
515            "untrusted cxxflags must be dropped"
516        );
517        assert!(
518            untrusted.ldflags.is_empty(),
519            "untrusted ldflags must be dropped"
520        );
521        // Validated, non-injection fields survive so dependencies can still
522        // declare their own defines / include search paths.
523        assert_eq!(untrusted.defines, vec!["DEP_DEFINE".to_owned()]);
524        assert_eq!(untrusted.include_dirs, vec![PathBuf::from("dep/include")]);
525
526        // The very same settings are kept verbatim for a trusted package.
527        let trusted = resolve_build_flags(&p, None, &host_for("linux"), true);
528        assert_eq!(trusted.cflags, vec!["-fplugin=evil.so".to_owned()]);
529        assert_eq!(
530            trusted.cxxflags,
531            vec!["-Xclang".to_owned(), "-load".to_owned(), "-B.".to_owned()]
532        );
533        assert_eq!(
534            trusted.ldflags,
535            vec![
536                "-fuse-ld=/tmp/evil".to_owned(),
537                "-specs=evil.specs".to_owned()
538            ]
539        );
540    }
541
542    #[test]
543    fn untrusted_package_still_receives_trusted_profile_layer() {
544        let mut p = ProfileSettings::default();
545        p.general.cxxflags = vec!["-fplugin=evil.so".into()];
546        let prof = ProfileFlags {
547            cxxflags: vec!["-O2".into()],
548            ldflags: vec!["-s".into()],
549            ..Default::default()
550        };
551        let r = resolve_build_flags(&p, Some(&prof), &host_for("linux"), false);
552        // The dependency's own flag is dropped, but the trusted root profile
553        // layer is still applied so the dependency builds with the user's
554        // selected flags.
555        assert_eq!(r.cxxflags, vec!["-O2".to_owned()]);
556        assert_eq!(r.ldflags, vec!["-s".to_owned()]);
557    }
558
559    #[test]
560    fn validate_rejects_absolute_include_dir() {
561        let decl = ProfileFlags {
562            include_dirs: vec![PathBuf::from("/etc/include")],
563            ..Default::default()
564        };
565        let err = decl.validate().unwrap_err();
566        assert!(matches!(
567            err,
568            BuildFlagsValidationError::AbsoluteIncludeDir { .. }
569        ));
570    }
571
572    #[test]
573    fn validate_rejects_parent_traversal_include_dir() {
574        let decl = ProfileFlags {
575            include_dirs: vec![PathBuf::from("../sneaky")],
576            ..Default::default()
577        };
578        let err = decl.validate().unwrap_err();
579        assert!(matches!(
580            err,
581            BuildFlagsValidationError::IncludeDirHasParent { .. }
582        ));
583    }
584
585    #[test]
586    fn validate_rejects_empty_define() {
587        let decl = ProfileFlags {
588            defines: vec![String::new()],
589            ..Default::default()
590        };
591        assert!(matches!(
592            decl.validate().unwrap_err(),
593            BuildFlagsValidationError::EmptyDefine
594        ));
595    }
596
597    #[test]
598    fn validate_rejects_define_missing_name() {
599        let decl = ProfileFlags {
600            defines: vec!["=oops".into()],
601            ..Default::default()
602        };
603        assert!(matches!(
604            decl.validate().unwrap_err(),
605            BuildFlagsValidationError::DefineMissingName { .. }
606        ));
607    }
608}