1use std::collections::BTreeSet;
21use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26use crate::condition::Condition;
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
35pub struct ProfileFlags {
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub defines: Vec<String>,
43 #[serde(
48 default,
49 rename = "include-dirs",
50 skip_serializing_if = "Vec::is_empty"
51 )]
52 pub include_dirs: Vec<PathBuf>,
53 #[serde(default, rename = "cflags", skip_serializing_if = "Vec::is_empty")]
58 pub cflags: Vec<String>,
59 #[serde(default, rename = "cxxflags", skip_serializing_if = "Vec::is_empty")]
65 pub cxxflags: Vec<String>,
66 #[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 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#[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#[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
143pub struct ResolvedProfileFlags {
144 pub defines: Vec<String>,
145 pub include_dirs: Vec<PathBuf>,
146 pub extra_compile_args: Vec<String>,
149 pub cflags: Vec<String>,
153 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 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
188pub 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 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
253macro_rules! append_profile_flag_layer {
268 ($target:expr, $layer:expr) => {{
269 let target = $target;
270 let layer = $layer;
271 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 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 let dedup: BTreeSet<String> = target.defines.drain(..).collect();
316 target.defines = dedup.into_iter().collect();
317 }
321
322#[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 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 assert_eq!(untrusted.defines, vec!["DEP_DEFINE".to_owned()]);
524 assert_eq!(untrusted.include_dirs, vec![PathBuf::from("dep/include")]);
525
526 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 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}