use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::condition::Condition;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileFlags {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub defines: Vec<String>,
#[serde(
default,
rename = "include-dirs",
skip_serializing_if = "Vec::is_empty"
)]
pub include_dirs: Vec<PathBuf>,
#[serde(default, rename = "cflags", skip_serializing_if = "Vec::is_empty")]
pub cflags: Vec<String>,
#[serde(default, rename = "cxxflags", skip_serializing_if = "Vec::is_empty")]
pub cxxflags: Vec<String>,
#[serde(default, rename = "ldflags", skip_serializing_if = "Vec::is_empty")]
pub ldflags: Vec<String>,
}
impl ProfileFlags {
pub fn is_empty(&self) -> bool {
self.defines.is_empty()
&& self.include_dirs.is_empty()
&& self.cflags.is_empty()
&& self.cxxflags.is_empty()
&& self.ldflags.is_empty()
}
pub fn validate(&self) -> Result<(), BuildFlagsValidationError> {
for define in &self.defines {
if define.is_empty() {
return Err(BuildFlagsValidationError::EmptyDefine);
}
if define.starts_with('=') {
return Err(BuildFlagsValidationError::DefineMissingName {
raw: define.clone(),
});
}
}
for dir in &self.include_dirs {
validate_include_dir(dir)?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConditionalProfileFlags {
pub condition: Condition,
#[serde(flatten, default, skip_serializing_if = "ProfileFlags::is_empty")]
pub flags: ProfileFlags,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileSettings {
#[serde(default, skip_serializing_if = "ProfileFlags::is_empty")]
pub general: ProfileFlags,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conditional: Vec<ConditionalProfileFlags>,
}
impl ProfileSettings {
pub fn is_empty(&self) -> bool {
self.general.is_empty() && self.conditional.is_empty()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedProfileFlags {
pub defines: Vec<String>,
pub include_dirs: Vec<PathBuf>,
pub extra_compile_args: Vec<String>,
pub cflags: Vec<String>,
pub cxxflags: Vec<String>,
pub ldflags: Vec<String>,
}
impl ResolvedProfileFlags {
pub fn is_empty(&self) -> bool {
self.defines.is_empty()
&& self.include_dirs.is_empty()
&& self.extra_compile_args.is_empty()
&& self.cflags.is_empty()
&& self.cxxflags.is_empty()
&& self.ldflags.is_empty()
}
pub fn as_json(&self) -> serde_json::Value {
serde_json::json!({
"defines": self.defines,
"include_dirs": self
.include_dirs
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>(),
"extra_compile_args": self.extra_compile_args,
"cflags": self.cflags,
"cxxflags": self.cxxflags,
"ldflags": self.ldflags,
})
}
}
pub fn resolve_build_flags(
package: &ProfileSettings,
profile: Option<&ProfileFlags>,
host_platform: &crate::condition::TargetPlatform,
package_trusted: bool,
) -> ResolvedProfileFlags {
let mut out = ResolvedProfileFlags::default();
apply_layer(&mut out, &package.general);
for conditional in &package.conditional {
if conditional.condition.evaluate(host_platform) {
apply_layer(&mut out, &conditional.flags);
}
}
if !package_trusted {
out.cflags.clear();
out.cxxflags.clear();
out.ldflags.clear();
}
if let Some(prof) = profile {
apply_layer(&mut out, prof);
}
finalize(&mut out);
out
}
macro_rules! append_profile_flag_layer {
($target:expr, $layer:expr) => {{
let target = $target;
let layer = $layer;
target.defines.extend(layer.defines.iter().cloned());
for inc in &layer.include_dirs {
if !target.include_dirs.iter().any(|existing| existing == inc) {
target.include_dirs.push(inc.clone());
}
}
target.cflags.extend(layer.cflags.iter().cloned());
target.cxxflags.extend(layer.cxxflags.iter().cloned());
target.ldflags.extend(layer.ldflags.iter().cloned());
}};
}
impl ProfileFlags {
pub(crate) fn append_layer(&mut self, layer: &ProfileFlags) {
append_profile_flag_layer!(self, layer);
}
}
fn apply_layer(target: &mut ResolvedProfileFlags, layer: &ProfileFlags) {
append_profile_flag_layer!(target, layer);
}
fn finalize(target: &mut ResolvedProfileFlags) {
let dedup: BTreeSet<String> = target.defines.drain(..).collect();
target.defines = dedup.into_iter().collect();
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum BuildFlagsValidationError {
#[error("[profile] declares an empty define entry")]
EmptyDefine,
#[error("[profile] define entry {raw:?} is missing a name")]
DefineMissingName { raw: String },
#[error(
"[profile] include directory {path:?} must be a relative path; absolute paths are not allowed"
)]
AbsoluteIncludeDir { path: String },
#[error(
"[profile] include directory {path:?} must not contain `..`; include search paths cannot escape the package root"
)]
IncludeDirHasParent { path: String },
#[error("[profile] include directory {path:?} contains a non-UTF-8 component")]
NonUtf8IncludeDir { path: String },
}
fn validate_include_dir(dir: &Path) -> Result<(), BuildFlagsValidationError> {
if dir.is_absolute() {
return Err(BuildFlagsValidationError::AbsoluteIncludeDir {
path: display_path(dir),
});
}
for component in dir.components() {
match component {
std::path::Component::ParentDir => {
return Err(BuildFlagsValidationError::IncludeDirHasParent {
path: display_path(dir),
});
}
std::path::Component::Prefix(_) | std::path::Component::RootDir => {
return Err(BuildFlagsValidationError::AbsoluteIncludeDir {
path: display_path(dir),
});
}
std::path::Component::Normal(part) => {
if part.to_str().is_none() {
return Err(BuildFlagsValidationError::NonUtf8IncludeDir {
path: display_path(dir),
});
}
}
std::path::Component::CurDir => {}
}
}
Ok(())
}
fn display_path(dir: &Path) -> String {
dir.display().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::condition::{ConditionKey, TargetPlatform};
fn host_for(os: &str) -> TargetPlatform {
let mut p = TargetPlatform::current();
p.os = os.to_owned();
p
}
#[test]
fn empty_settings_resolve_to_empty_flags() {
let p = ProfileSettings::default();
let r = resolve_build_flags(&p, None, &host_for("linux"), true);
assert!(r.is_empty());
}
#[test]
fn defines_merge_dedup_and_sort() {
let mut p = ProfileSettings::default();
p.general.defines = vec!["B".into(), "A".into(), "B".into()];
let r = resolve_build_flags(&p, None, &host_for("linux"), true);
assert_eq!(r.defines, vec!["A".to_owned(), "B".to_owned()]);
}
#[test]
fn include_dirs_keep_first_occurrence_order() {
let mut p = ProfileSettings::default();
p.general.include_dirs = vec![
PathBuf::from("include"),
PathBuf::from("third_party/include"),
PathBuf::from("include"),
];
let r = resolve_build_flags(&p, None, &host_for("linux"), true);
assert_eq!(
r.include_dirs,
vec![
PathBuf::from("include"),
PathBuf::from("third_party/include"),
]
);
}
#[test]
fn matching_conditional_layer_is_applied() {
let mut p = ProfileSettings::default();
p.general.defines = vec!["BASE".into()];
p.conditional.push(ConditionalProfileFlags {
condition: Condition::KeyValue {
key: ConditionKey::Os,
value: "linux".into(),
},
flags: ProfileFlags {
defines: vec!["LINUX_ONLY".into()],
..Default::default()
},
});
let r = resolve_build_flags(&p, None, &host_for("linux"), true);
assert_eq!(r.defines, vec!["BASE".to_owned(), "LINUX_ONLY".to_owned()]);
}
#[test]
fn non_matching_conditional_layer_is_skipped() {
let mut p = ProfileSettings::default();
p.general.defines = vec!["BASE".into()];
p.conditional.push(ConditionalProfileFlags {
condition: Condition::KeyValue {
key: ConditionKey::Os,
value: "macos".into(),
},
flags: ProfileFlags {
defines: vec!["MAC_ONLY".into()],
..Default::default()
},
});
let r = resolve_build_flags(&p, None, &host_for("linux"), true);
assert_eq!(r.defines, vec!["BASE".to_owned()]);
}
#[test]
fn profile_layer_appends_after_target_conditional() {
let mut p = ProfileSettings::default();
p.general.cxxflags = vec!["-fPIC".into()];
p.conditional.push(ConditionalProfileFlags {
condition: Condition::KeyValue {
key: ConditionKey::Os,
value: "linux".into(),
},
flags: ProfileFlags {
cxxflags: vec!["-flto=thin".into()],
..Default::default()
},
});
let prof = ProfileFlags {
cxxflags: vec!["-Wall".into()],
..Default::default()
};
let r = resolve_build_flags(&p, Some(&prof), &host_for("linux"), true);
assert_eq!(
r.cxxflags,
vec![
"-fPIC".to_owned(),
"-flto=thin".to_owned(),
"-Wall".to_owned(),
]
);
}
#[test]
fn untrusted_package_drops_command_flags_but_keeps_defines_and_includes() {
let mut p = ProfileSettings::default();
p.general.defines = vec!["DEP_DEFINE".into()];
p.general.include_dirs = vec![PathBuf::from("dep/include")];
p.general.cflags = vec!["-fplugin=evil.so".into()];
p.general.cxxflags = vec!["-Xclang".into(), "-load".into()];
p.general.ldflags = vec!["-fuse-ld=/tmp/evil".into()];
p.conditional.push(ConditionalProfileFlags {
condition: Condition::KeyValue {
key: ConditionKey::Os,
value: "linux".into(),
},
flags: ProfileFlags {
cxxflags: vec!["-B.".into()],
ldflags: vec!["-specs=evil.specs".into()],
..Default::default()
},
});
let untrusted = resolve_build_flags(&p, None, &host_for("linux"), false);
assert!(
untrusted.cflags.is_empty(),
"untrusted cflags must be dropped"
);
assert!(
untrusted.cxxflags.is_empty(),
"untrusted cxxflags must be dropped"
);
assert!(
untrusted.ldflags.is_empty(),
"untrusted ldflags must be dropped"
);
assert_eq!(untrusted.defines, vec!["DEP_DEFINE".to_owned()]);
assert_eq!(untrusted.include_dirs, vec![PathBuf::from("dep/include")]);
let trusted = resolve_build_flags(&p, None, &host_for("linux"), true);
assert_eq!(trusted.cflags, vec!["-fplugin=evil.so".to_owned()]);
assert_eq!(
trusted.cxxflags,
vec!["-Xclang".to_owned(), "-load".to_owned(), "-B.".to_owned()]
);
assert_eq!(
trusted.ldflags,
vec![
"-fuse-ld=/tmp/evil".to_owned(),
"-specs=evil.specs".to_owned()
]
);
}
#[test]
fn untrusted_package_still_receives_trusted_profile_layer() {
let mut p = ProfileSettings::default();
p.general.cxxflags = vec!["-fplugin=evil.so".into()];
let prof = ProfileFlags {
cxxflags: vec!["-O2".into()],
ldflags: vec!["-s".into()],
..Default::default()
};
let r = resolve_build_flags(&p, Some(&prof), &host_for("linux"), false);
assert_eq!(r.cxxflags, vec!["-O2".to_owned()]);
assert_eq!(r.ldflags, vec!["-s".to_owned()]);
}
#[test]
fn validate_rejects_absolute_include_dir() {
let decl = ProfileFlags {
include_dirs: vec![PathBuf::from("/etc/include")],
..Default::default()
};
let err = decl.validate().unwrap_err();
assert!(matches!(
err,
BuildFlagsValidationError::AbsoluteIncludeDir { .. }
));
}
#[test]
fn validate_rejects_parent_traversal_include_dir() {
let decl = ProfileFlags {
include_dirs: vec![PathBuf::from("../sneaky")],
..Default::default()
};
let err = decl.validate().unwrap_err();
assert!(matches!(
err,
BuildFlagsValidationError::IncludeDirHasParent { .. }
));
}
#[test]
fn validate_rejects_empty_define() {
let decl = ProfileFlags {
defines: vec![String::new()],
..Default::default()
};
assert!(matches!(
decl.validate().unwrap_err(),
BuildFlagsValidationError::EmptyDefine
));
}
#[test]
fn validate_rejects_define_missing_name() {
let decl = ProfileFlags {
defines: vec!["=oops".into()],
..Default::default()
};
assert!(matches!(
decl.validate().unwrap_err(),
BuildFlagsValidationError::DefineMissingName { .. }
));
}
}