use std::collections::{HashMap, HashSet};
use serde_json::Value;
use super::parse::{lint_ids_from_catalog, ParseError};
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ResolvedProfile {
pub control_ids: Vec<String>,
pub parameter_overrides: Vec<ParameterOverride>,
}
impl ResolvedProfile {
#[must_use]
pub fn new(control_ids: Vec<String>, parameter_overrides: Vec<ParameterOverride>) -> Self {
Self {
control_ids,
parameter_overrides,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ParameterOverride {
pub param_id: String,
pub value: String,
}
impl ParameterOverride {
#[must_use]
pub fn new(param_id: impl Into<String>, value: impl Into<String>) -> Self {
Self {
param_id: param_id.into(),
value: value.into(),
}
}
}
pub fn resolve_profile(
profile: &Value,
sources: &HashMap<String, Value>,
) -> Result<ResolvedProfile, ParseError> {
let mut stack: HashSet<String> = HashSet::new();
resolve_profile_inner(profile, sources, &mut stack)
}
fn resolve_profile_inner(
profile: &Value,
sources: &HashMap<String, Value>,
stack: &mut HashSet<String>,
) -> Result<ResolvedProfile, ParseError> {
let obj = profile.as_object().ok_or(ParseError::ProfileNotObject)?;
let prof = obj
.get("profile")
.and_then(|p| p.as_object())
.ok_or(ParseError::ProfileMissingWrapper)?;
super::parse::check_oscal_version(prof)?;
let imports = prof
.get("imports")
.and_then(|i| i.as_array())
.ok_or(ParseError::ProfileImportsNotArray)?;
let mut control_ids: Vec<String> = Vec::new();
let mut seen_ids: HashSet<String> = HashSet::new();
let mut parameter_overrides: Vec<ParameterOverride> = Vec::new();
for (import_index, import) in imports.iter().enumerate() {
let import_obj = import
.as_object()
.ok_or(ParseError::ProfileImportNotObject {
index: import_index,
})?;
let href_value = import_obj
.get("href")
.ok_or(ParseError::ProfileImportMissingHref {
index: import_index,
})?;
let href = href_value
.as_str()
.ok_or(ParseError::ProfileImportHrefNotString {
index: import_index,
})?;
if href.is_empty() {
return Err(ParseError::ProfileImportHrefEmpty {
index: import_index,
});
}
let source = sources
.get(href)
.ok_or_else(|| ParseError::ProfileImportUnresolved {
index: import_index,
href: href.to_owned(),
})?;
let source_obj =
source
.as_object()
.ok_or_else(|| ParseError::ProfileImportSourceUnknown {
index: import_index,
href: href.to_owned(),
})?;
let (mut available_ids, mut nested_overrides) = if source_obj.contains_key("profile") {
if !stack.insert(href.to_owned()) {
return Err(ParseError::ProfileImportCycle {
href: href.to_owned(),
});
}
let nested = resolve_profile_inner(source, sources, stack)?;
stack.remove(href);
(nested.control_ids, nested.parameter_overrides)
} else if source_obj.contains_key("catalog") {
let ids = lint_ids_from_catalog(source)?;
(ids, Vec::new())
} else {
return Err(ParseError::ProfileImportSourceUnknown {
index: import_index,
href: href.to_owned(),
});
};
let include_all = import_obj.get("include-all").is_some();
let include_directives = import_obj.get("include-controls");
let included: Vec<String> = if include_all {
available_ids.clone()
} else if let Some(directives) = include_directives {
let entries =
directives
.as_array()
.ok_or(ParseError::ProfileIncludeControlsNotArray {
index: import_index,
})?;
let mut wanted: HashSet<String> = HashSet::new();
for (entry_index, entry) in entries.iter().enumerate() {
let entry_obj =
entry
.as_object()
.ok_or(ParseError::ProfileWithIdsEntryNotObject {
index: import_index,
entry_index,
})?;
if let Some(with_ids) = entry_obj.get("with-ids") {
let ids = with_ids
.as_array()
.ok_or(ParseError::ProfileWithIdsNotArray {
index: import_index,
entry_index,
})?;
for id_val in ids {
let id_str = id_val.as_str().ok_or(ParseError::ProfileWithIdNotString {
index: import_index,
entry_index,
})?;
wanted.insert(id_str.to_owned());
}
}
}
available_ids.retain(|id| wanted.contains(id));
available_ids.clone()
} else {
Vec::new()
};
let mut excluded: HashSet<String> = HashSet::new();
if let Some(directives) = import_obj.get("exclude-controls") {
let entries =
directives
.as_array()
.ok_or(ParseError::ProfileExcludeControlsNotArray {
index: import_index,
})?;
for (entry_index, entry) in entries.iter().enumerate() {
let entry_obj =
entry
.as_object()
.ok_or(ParseError::ProfileWithIdsEntryNotObject {
index: import_index,
entry_index,
})?;
if let Some(with_ids) = entry_obj.get("with-ids") {
let ids = with_ids
.as_array()
.ok_or(ParseError::ProfileWithIdsNotArray {
index: import_index,
entry_index,
})?;
for id_val in ids {
let id_str = id_val.as_str().ok_or(ParseError::ProfileWithIdNotString {
index: import_index,
entry_index,
})?;
excluded.insert(id_str.to_owned());
}
}
}
}
for id in included {
if excluded.contains(&id) {
continue;
}
if seen_ids.insert(id.clone()) {
control_ids.push(id);
}
}
parameter_overrides.append(&mut nested_overrides);
}
if let Some(modify) = prof.get("modify").and_then(|m| m.as_object()) {
if let Some(set_params) = modify.get("set-parameters") {
let entries = set_params
.as_array()
.ok_or(ParseError::ProfileSetParametersNotArray)?;
for (entry_index, entry) in entries.iter().enumerate() {
let entry_obj = entry
.as_object()
.ok_or(ParseError::ProfileSetParameterNotObject { entry_index })?;
let param_id = entry_obj
.get("param-id")
.and_then(|v| v.as_str())
.ok_or(ParseError::ProfileSetParameterMissingId { entry_index })?;
if param_id.is_empty() {
return Err(ParseError::ProfileSetParameterIdEmpty { entry_index });
}
let values = entry_obj
.get("values")
.and_then(|v| v.as_array())
.ok_or(ParseError::ProfileSetParameterValuesNotArray { entry_index })?;
if values.is_empty() {
return Err(ParseError::ProfileSetParameterValuesEmpty { entry_index });
}
let value = values[0]
.as_str()
.ok_or(ParseError::ProfileSetParameterValueNotString { entry_index })?;
parameter_overrides.push(ParameterOverride {
param_id: param_id.to_owned(),
value: value.to_owned(),
});
}
}
}
Ok(ResolvedProfile {
control_ids,
parameter_overrides,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oscal::catalog::catalog_from_lints;
use crate::rfc5280::Rfc5280MaxSerialLengthLint;
use crate::{Lint, LintResult, Scope, Severity, SubjectKind};
use serde_json::json;
use x509_cert::Certificate;
#[derive(Clone)]
struct PolicyShapedLint;
impl Lint for PolicyShapedLint {
fn id(&self) -> &'static str {
"test.policy.shaped"
}
fn citation(&self) -> &'static str {
"Test Policy §1.2.3"
}
fn severity(&self) -> Severity {
Severity::Error
}
fn scope(&self) -> Scope {
Scope::Certificate
}
fn applies_to(&self) -> SubjectKind {
SubjectKind::Leaf
}
fn spec_section_id(&self) -> Option<&str> {
Some("test-policy-1.2.3")
}
fn check_cert(
&self,
_cert: &Certificate,
_kind: SubjectKind,
_now_unix: u64,
) -> LintResult {
LintResult::Pass
}
}
fn rfc_catalog() -> Value {
let lints: Vec<Box<dyn Lint>> = vec![Box::new(Rfc5280MaxSerialLengthLint::default())];
catalog_from_lints(&lints, "rs.pkix.rfc5280", "0.1.0")
}
fn policy_catalog() -> Value {
let lints: Vec<Box<dyn Lint>> = vec![Box::new(PolicyShapedLint)];
catalog_from_lints(&lints, "rs.pkix.policy.fixture", "0.1.0")
}
#[test]
fn plain_profile_include_all() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
let profile = json!({
"profile": {
"uuid": "00000000-0000-0000-0000-000000000001",
"metadata": { "title": "plain", "oscal-version": "1.1.2" },
"imports": [
{ "href": "#rs.pkix.rfc5280", "include-all": {} }
],
"back-matter": {}
}
});
let resolved = resolve_profile(&profile, &sources).expect("resolve");
assert_eq!(
resolved.control_ids,
vec!["rfc5280.cert.serial_number.max_octets".to_owned()]
);
assert!(resolved.parameter_overrides.is_empty());
}
#[test]
fn plain_profile_explicit_include_controls() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
let profile = json!({
"profile": {
"uuid": "00000000-0000-0000-0000-000000000002",
"metadata": { "title": "explicit", "oscal-version": "1.1.2" },
"imports": [
{
"href": "#rs.pkix.rfc5280",
"include-controls": [
{ "with-ids": ["rfc5280.cert.serial_number.max_octets"] }
]
}
]
}
});
let resolved = resolve_profile(&profile, &sources).expect("resolve");
assert_eq!(
resolved.control_ids,
vec!["rfc5280.cert.serial_number.max_octets".to_owned()]
);
}
#[test]
fn layered_profile_imports_two_catalogs_with_overrides() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
sources.insert("#rs.pkix.policy.fixture".to_owned(), policy_catalog());
let profile = json!({
"profile": {
"uuid": "00000000-0000-0000-0000-000000000003",
"metadata": { "title": "policy-fixture", "oscal-version": "1.1.2" },
"imports": [
{
"href": "#rs.pkix.rfc5280",
"include-all": {}
},
{
"href": "#rs.pkix.policy.fixture",
"include-all": {}
}
],
"modify": {
"set-parameters": [
{
"param-id": "rfc5280.cert.serial_number.max_octets.max-octets",
"values": ["16"]
}
]
}
}
});
let resolved = resolve_profile(&profile, &sources).expect("resolve");
assert_eq!(
resolved.control_ids,
vec![
"rfc5280.cert.serial_number.max_octets".to_owned(),
"test.policy.shaped".to_owned(),
]
);
assert_eq!(
resolved.parameter_overrides,
vec![ParameterOverride {
param_id: "rfc5280.cert.serial_number.max_octets.max-octets".to_owned(),
value: "16".to_owned(),
}]
);
}
#[test]
fn override_profile_imports_profile_and_excludes_one_control() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
sources.insert("#rs.pkix.policy.fixture".to_owned(), policy_catalog());
let inner = json!({
"profile": {
"uuid": "00000000-0000-0000-0000-00000000abcd",
"metadata": { "title": "inner", "oscal-version": "1.1.2" },
"imports": [
{ "href": "#rs.pkix.rfc5280", "include-all": {} },
{ "href": "#rs.pkix.policy.fixture", "include-all": {} }
]
}
});
sources.insert("#pkix.profile.inner".to_owned(), inner);
let outer = json!({
"profile": {
"uuid": "00000000-0000-0000-0000-00000000ef00",
"metadata": { "title": "outer-customer-deviation", "oscal-version": "1.1.2" },
"imports": [
{
"href": "#pkix.profile.inner",
"include-all": {},
"exclude-controls": [
{ "with-ids": ["test.policy.shaped"] }
]
}
],
"modify": {
"set-parameters": [
{
"param-id": "rfc5280.cert.serial_number.max_octets.max-octets",
"values": ["8"]
}
]
}
}
});
let resolved = resolve_profile(&outer, &sources).expect("resolve");
assert_eq!(
resolved.control_ids,
vec!["rfc5280.cert.serial_number.max_octets".to_owned()]
);
assert_eq!(
resolved.parameter_overrides,
vec![ParameterOverride {
param_id: "rfc5280.cert.serial_number.max_octets.max-octets".to_owned(),
value: "8".to_owned(),
}]
);
}
#[test]
fn override_profile_inherits_inner_overrides_before_its_own() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
let inner = json!({
"profile": {
"uuid": "00000000-0000-0000-0000-00000000a1a1",
"metadata": { "title": "inner-with-override", "oscal-version": "1.1.2" },
"imports": [
{ "href": "#rs.pkix.rfc5280", "include-all": {} }
],
"modify": {
"set-parameters": [
{
"param-id": "rfc5280.cert.serial_number.max_octets.max-octets",
"values": ["16"]
}
]
}
}
});
sources.insert("#pkix.profile.inner".to_owned(), inner);
let outer = json!({
"profile": {
"uuid": "00000000-0000-0000-0000-00000000a2a2",
"metadata": { "title": "outer-with-tighter-override", "oscal-version": "1.1.2" },
"imports": [
{ "href": "#pkix.profile.inner", "include-all": {} }
],
"modify": {
"set-parameters": [
{
"param-id": "rfc5280.cert.serial_number.max_octets.max-octets",
"values": ["8"]
}
]
}
}
});
let resolved = resolve_profile(&outer, &sources).expect("resolve");
assert_eq!(
resolved.parameter_overrides,
vec![
ParameterOverride {
param_id: "rfc5280.cert.serial_number.max_octets.max-octets".to_owned(),
value: "16".to_owned(),
},
ParameterOverride {
param_id: "rfc5280.cert.serial_number.max_octets.max-octets".to_owned(),
value: "8".to_owned(),
},
]
);
}
#[test]
fn exclude_after_include_drops_id() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
let profile = json!({
"profile": {
"uuid": "00000000-0000-0000-0000-00000000ccdd",
"metadata": { "title": "exclude-test", "oscal-version": "1.1.2" },
"imports": [
{
"href": "#rs.pkix.rfc5280",
"include-all": {},
"exclude-controls": [
{ "with-ids": ["rfc5280.cert.serial_number.max_octets"] }
]
}
]
}
});
let resolved = resolve_profile(&profile, &sources).expect("resolve");
assert!(resolved.control_ids.is_empty());
}
#[test]
fn import_without_include_directive_yields_no_controls() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
let profile = json!({
"profile": {
"uuid": "00000000-0000-0000-0000-00000000bbcc",
"metadata": { "title": "no-include", "oscal-version": "1.1.2" },
"imports": [
{ "href": "#rs.pkix.rfc5280" }
]
}
});
let resolved = resolve_profile(&profile, &sources).expect("resolve");
assert!(resolved.control_ids.is_empty());
}
#[test]
fn duplicate_id_across_imports_dedup_first_wins() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
sources.insert("#rs.pkix.rfc5280.alt".to_owned(), rfc_catalog());
let profile = json!({
"profile": {
"uuid": "00000000-0000-0000-0000-00000000dd11",
"metadata": { "title": "dup", "oscal-version": "1.1.2" },
"imports": [
{ "href": "#rs.pkix.rfc5280", "include-all": {} },
{ "href": "#rs.pkix.rfc5280.alt", "include-all": {} }
]
}
});
let resolved = resolve_profile(&profile, &sources).expect("resolve");
assert_eq!(
resolved.control_ids,
vec!["rfc5280.cert.serial_number.max_octets".to_owned()],
"duplicate id from two imports should appear once"
);
}
#[test]
fn err_profile_not_object() {
let sources = HashMap::new();
let err = resolve_profile(&Value::Null, &sources).unwrap_err();
assert!(matches!(err, ParseError::ProfileNotObject));
}
#[test]
fn err_profile_missing_wrapper() {
let sources = HashMap::new();
let v = json!({ "not-a-profile": {} });
let err = resolve_profile(&v, &sources).unwrap_err();
assert!(matches!(err, ParseError::ProfileMissingWrapper));
}
#[test]
fn err_imports_not_array() {
let sources = HashMap::new();
let v = json!({ "profile": { "metadata": {"oscal-version": "1.1.2"}, "imports": {} } });
let err = resolve_profile(&v, &sources).unwrap_err();
assert!(matches!(err, ParseError::ProfileImportsNotArray));
}
#[test]
fn err_profile_missing_oscal_version() {
let sources = HashMap::new();
let v = json!({ "profile": { "imports": [] } });
let err = resolve_profile(&v, &sources).unwrap_err();
assert!(matches!(err, ParseError::MissingOscalVersion));
}
#[test]
fn err_profile_unsupported_oscal_version() {
let sources = HashMap::new();
for found in ["1.0.4", "1.2.0"] {
let v = json!({
"profile": {
"metadata": {"oscal-version": found},
"imports": []
}
});
match resolve_profile(&v, &sources) {
Err(ParseError::UnsupportedOscalVersion { found: got }) => {
assert_eq!(got, found);
}
other => panic!(
"expected UnsupportedOscalVersion for version {found}; got: {other:?}"
),
}
}
}
#[test]
fn err_nested_profile_missing_oscal_version() {
let mut sources = HashMap::new();
sources.insert(
"#inner-no-version".to_owned(),
json!({ "profile": { "imports": [] } }),
);
let outer = json!({
"profile": {
"metadata": {"oscal-version": "1.1.2"},
"imports": [ { "href": "#inner-no-version" } ]
}
});
let err = resolve_profile(&outer, &sources).unwrap_err();
assert!(matches!(err, ParseError::MissingOscalVersion));
}
#[test]
fn err_import_missing_href() {
let sources = HashMap::new();
let v = json!({ "profile": { "metadata": {"oscal-version": "1.1.2"}, "imports": [ {} ] } });
let err = resolve_profile(&v, &sources).unwrap_err();
assert!(matches!(
err,
ParseError::ProfileImportMissingHref { index: 0 }
));
}
#[test]
fn err_import_href_not_string() {
let sources = HashMap::new();
let v = json!({ "profile": { "metadata": {"oscal-version": "1.1.2"}, "imports": [ { "href": 7 } ] } });
let err = resolve_profile(&v, &sources).unwrap_err();
assert!(matches!(
err,
ParseError::ProfileImportHrefNotString { index: 0 }
));
}
#[test]
fn err_import_href_empty() {
let sources = HashMap::new();
let v = json!({ "profile": { "metadata": {"oscal-version": "1.1.2"}, "imports": [ { "href": "" } ] } });
let err = resolve_profile(&v, &sources).unwrap_err();
assert!(matches!(
err,
ParseError::ProfileImportHrefEmpty { index: 0 }
));
}
#[test]
fn err_import_unresolved() {
let sources = HashMap::new();
let v = json!({ "profile": { "metadata": {"oscal-version": "1.1.2"}, "imports": [ { "href": "#nope" } ] } });
let err = resolve_profile(&v, &sources).unwrap_err();
match err {
ParseError::ProfileImportUnresolved { index: 0, href } => {
assert_eq!(href, "#nope");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn err_import_source_unknown() {
let mut sources = HashMap::new();
sources.insert(
"#weird".to_owned(),
json!({ "neither-catalog-nor-profile": {} }),
);
let v = json!({ "profile": { "metadata": {"oscal-version": "1.1.2"}, "imports": [ { "href": "#weird" } ] } });
let err = resolve_profile(&v, &sources).unwrap_err();
match err {
ParseError::ProfileImportSourceUnknown { index: 0, href } => {
assert_eq!(href, "#weird");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn err_import_cycle() {
let mut sources = HashMap::new();
sources.insert(
"#a".to_owned(),
json!({ "profile": { "metadata": {"oscal-version": "1.1.2"}, "imports": [ { "href": "#b" } ] } }),
);
sources.insert(
"#b".to_owned(),
json!({ "profile": { "metadata": {"oscal-version": "1.1.2"}, "imports": [ { "href": "#a" } ] } }),
);
let outer = json!({ "profile": { "metadata": {"oscal-version": "1.1.2"}, "imports": [ { "href": "#a" } ] } });
let err = resolve_profile(&outer, &sources).unwrap_err();
match err {
ParseError::ProfileImportCycle { href } => {
assert!(href == "#a" || href == "#b");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn err_include_controls_not_array() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
let v = json!({
"profile": {
"metadata": {"oscal-version": "1.1.2"},
"imports": [
{ "href": "#rs.pkix.rfc5280", "include-controls": {} }
]
}
});
let err = resolve_profile(&v, &sources).unwrap_err();
assert!(matches!(
err,
ParseError::ProfileIncludeControlsNotArray { index: 0 }
));
}
#[test]
fn err_exclude_controls_not_array() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
let v = json!({
"profile": {
"metadata": {"oscal-version": "1.1.2"},
"imports": [
{
"href": "#rs.pkix.rfc5280",
"include-all": {},
"exclude-controls": "string-not-array"
}
]
}
});
let err = resolve_profile(&v, &sources).unwrap_err();
assert!(matches!(
err,
ParseError::ProfileExcludeControlsNotArray { index: 0 }
));
}
#[test]
fn err_with_ids_not_array() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
let v = json!({
"profile": {
"metadata": {"oscal-version": "1.1.2"},
"imports": [
{
"href": "#rs.pkix.rfc5280",
"include-controls": [ { "with-ids": "not-an-array" } ]
}
]
}
});
let err = resolve_profile(&v, &sources).unwrap_err();
assert!(matches!(
err,
ParseError::ProfileWithIdsNotArray {
index: 0,
entry_index: 0
}
));
}
#[test]
fn err_with_id_not_string() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
let v = json!({
"profile": {
"metadata": {"oscal-version": "1.1.2"},
"imports": [
{
"href": "#rs.pkix.rfc5280",
"include-controls": [ { "with-ids": [42] } ]
}
]
}
});
let err = resolve_profile(&v, &sources).unwrap_err();
assert!(matches!(
err,
ParseError::ProfileWithIdNotString {
index: 0,
entry_index: 0
}
));
}
#[test]
fn err_set_parameters_not_array() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
let v = json!({
"profile": {
"metadata": {"oscal-version": "1.1.2"},
"imports": [
{ "href": "#rs.pkix.rfc5280", "include-all": {} }
],
"modify": { "set-parameters": "nope" }
}
});
let err = resolve_profile(&v, &sources).unwrap_err();
assert!(matches!(err, ParseError::ProfileSetParametersNotArray));
}
#[test]
fn err_set_parameter_missing_id() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
let v = json!({
"profile": {
"metadata": {"oscal-version": "1.1.2"},
"imports": [
{ "href": "#rs.pkix.rfc5280", "include-all": {} }
],
"modify": { "set-parameters": [ { "values": ["x"] } ] }
}
});
let err = resolve_profile(&v, &sources).unwrap_err();
assert!(matches!(
err,
ParseError::ProfileSetParameterMissingId { entry_index: 0 }
));
}
#[test]
fn err_set_parameter_values_empty() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
let v = json!({
"profile": {
"metadata": {"oscal-version": "1.1.2"},
"imports": [
{ "href": "#rs.pkix.rfc5280", "include-all": {} }
],
"modify": { "set-parameters": [ { "param-id": "x", "values": [] } ] }
}
});
let err = resolve_profile(&v, &sources).unwrap_err();
assert!(matches!(
err,
ParseError::ProfileSetParameterValuesEmpty { entry_index: 0 }
));
}
#[test]
fn err_set_parameter_value_not_string() {
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
let v = json!({
"profile": {
"metadata": {"oscal-version": "1.1.2"},
"imports": [
{ "href": "#rs.pkix.rfc5280", "include-all": {} }
],
"modify": { "set-parameters": [ { "param-id": "x", "values": [7] } ] }
}
});
let err = resolve_profile(&v, &sources).unwrap_err();
assert!(matches!(
err,
ParseError::ProfileSetParameterValueNotString { entry_index: 0 }
));
}
#[test]
fn end_to_end_profile_override_changes_runner_behavior() {
use crate::{LintResult, LintRunner, SubjectKind};
use x509_cert::Certificate;
let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../pkix-path/tests/fixtures/policy-checks/")
.join("leaf-rsa2048-sha1.der");
let der = std::fs::read(&fixture_path)
.unwrap_or_else(|e| panic!("read fixture {}: {e}", fixture_path.display()));
let cert = <Certificate as der::Decode>::from_der(&der).expect("decode fixture");
assert_eq!(
cert.tbs_certificate.serial_number.as_bytes().len(),
20,
"fixture invariant: leaf-rsa2048-sha1.der has a 20-octet serial",
);
let lints: Vec<Box<dyn Lint>> = vec![Box::new(Rfc5280MaxSerialLengthLint::default())];
let mut runner = LintRunner::new(lints);
let baseline = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
let baseline_result = baseline
.iter()
.find(|f| f.lint_id == "rfc5280.cert.serial_number.max_octets")
.map(|f| f.result.clone())
.expect("rfc5280 finding present");
assert!(
matches!(baseline_result, LintResult::Pass),
"default cap (20) must pass a 20-octet serial; got {baseline_result:?}",
);
let mut sources = HashMap::new();
sources.insert("#rs.pkix.rfc5280".to_owned(), rfc_catalog());
let profile = json!({
"profile": {
"uuid": "00000000-0000-0000-0000-000000000099",
"metadata": { "title": "e2e-tighten", "oscal-version": "1.1.2" },
"imports": [
{ "href": "#rs.pkix.rfc5280", "include-all": {} }
],
"modify": {
"set-parameters": [
{
"param-id": "rfc5280.cert.serial_number.max_octets.max-octets",
"values": ["10"]
}
]
}
}
});
let resolved = resolve_profile(&profile, &sources).expect("resolve");
assert_eq!(resolved.parameter_overrides.len(), 1);
runner
.apply_parameter_overrides(&resolved.parameter_overrides)
.expect("apply overrides");
let filtered = runner
.filter_to_ids(&resolved.control_ids)
.expect("filter to ids");
let findings = filtered.run_cert(&cert, SubjectKind::Leaf, 0, 0);
let tightened_result = findings
.iter()
.find(|f| f.lint_id == "rfc5280.cert.serial_number.max_octets")
.map(|f| f.result.clone())
.expect("rfc5280 finding present");
match tightened_result {
LintResult::Error(detail) => {
assert!(detail.contains("20 octets"));
assert!(detail.contains("10 octets"));
}
other => panic!("tightened cap (10) must error on a 20-octet serial; got {other:?}"),
}
}
#[test]
fn apply_parameter_overrides_unknown_lint_errors() {
use crate::LintRunner;
let lints: Vec<Box<dyn Lint>> = vec![Box::new(Rfc5280MaxSerialLengthLint::default())];
let mut runner = LintRunner::new(lints);
let overrides = vec![ParameterOverride {
param_id: "no.such.lint.somewhere.max-octets".to_owned(),
value: "1".to_owned(),
}];
let err = runner.apply_parameter_overrides(&overrides).unwrap_err();
match err {
ParseError::UnknownParameterOverride { param_id } => {
assert_eq!(param_id, "no.such.lint.somewhere.max-octets");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn apply_parameter_overrides_unknown_param_id_errors() {
use crate::LintRunner;
let lints: Vec<Box<dyn Lint>> = vec![Box::new(Rfc5280MaxSerialLengthLint::default())];
let mut runner = LintRunner::new(lints);
let overrides = vec![ParameterOverride {
param_id: "rfc5280.cert.serial_number.max_octets.no-such-param".to_owned(),
value: "1".to_owned(),
}];
let err = runner.apply_parameter_overrides(&overrides).unwrap_err();
match err {
ParseError::InvalidParameterOverride { param_id, .. } => {
assert_eq!(
param_id,
"rfc5280.cert.serial_number.max_octets.no-such-param"
);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn apply_parameter_overrides_invalid_value_wraps_parameter_error() {
use crate::LintRunner;
let lints: Vec<Box<dyn Lint>> = vec![Box::new(Rfc5280MaxSerialLengthLint::default())];
let mut runner = LintRunner::new(lints);
let overrides = vec![ParameterOverride {
param_id: "rfc5280.cert.serial_number.max_octets.max-octets".to_owned(),
value: "not-a-number".to_owned(),
}];
let err = runner.apply_parameter_overrides(&overrides).unwrap_err();
match err {
ParseError::InvalidParameterOverride { param_id, source } => {
assert_eq!(param_id, "rfc5280.cert.serial_number.max_octets.max-octets");
assert!(matches!(source, crate::ParameterError::InvalidValue { .. }));
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn apply_parameter_overrides_id_without_dot_errors() {
use crate::LintRunner;
let lints: Vec<Box<dyn Lint>> = vec![Box::new(Rfc5280MaxSerialLengthLint::default())];
let mut runner = LintRunner::new(lints);
let overrides = vec![ParameterOverride {
param_id: "no_dot_separator".to_owned(),
value: "1".to_owned(),
}];
let err = runner.apply_parameter_overrides(&overrides).unwrap_err();
assert!(matches!(err, ParseError::UnknownParameterOverride { .. }));
}
#[derive(Clone)]
struct DottedParamLint;
impl Lint for DottedParamLint {
fn id(&self) -> &'static str {
"test.dotted.param"
}
fn citation(&self) -> &'static str {
"PKIX-hy2e.3 fixture"
}
fn severity(&self) -> crate::Severity {
crate::Severity::Warn
}
fn scope(&self) -> crate::Scope {
crate::Scope::Certificate
}
fn applies_to(&self) -> crate::SubjectKind {
crate::SubjectKind::Leaf
}
fn set_parameter(
&mut self,
id: &str,
_value: &str,
) -> Result<(), crate::ParameterError> {
if id == "thresholds.warn" {
Ok(())
} else {
Err(crate::ParameterError::UnknownParameter(id.to_owned()))
}
}
fn check_cert(
&self,
_cert: &x509_cert::Certificate,
_kind: crate::SubjectKind,
_now_unix: u64,
) -> crate::LintResult {
crate::LintResult::Pass
}
}
#[test]
fn apply_parameter_overrides_resolves_dotted_param_id() {
use crate::LintRunner;
let lints: Vec<Box<dyn Lint>> = vec![Box::new(DottedParamLint)];
let mut runner = LintRunner::new(lints);
let overrides = vec![ParameterOverride {
param_id: "test.dotted.param.thresholds.warn".to_owned(),
value: "5".to_owned(),
}];
runner.apply_parameter_overrides(&overrides).expect(
"longest-prefix match must split composite id at the lint-id boundary; \
rsplit-once-on-dot would have produced param_id='warn' and triggered \
InvalidParameterOverride",
);
}
#[test]
fn apply_parameter_overrides_longest_prefix_wins_on_lint_id_collision() {
use crate::LintRunner;
#[derive(Clone)]
struct ShortPrefixLint;
impl Lint for ShortPrefixLint {
fn id(&self) -> &'static str {
"test.prefix"
}
fn citation(&self) -> &'static str {
"fixture"
}
fn severity(&self) -> crate::Severity {
crate::Severity::Warn
}
fn scope(&self) -> crate::Scope {
crate::Scope::Certificate
}
fn applies_to(&self) -> crate::SubjectKind {
crate::SubjectKind::Leaf
}
fn set_parameter(
&mut self,
id: &str,
_value: &str,
) -> Result<(), crate::ParameterError> {
Err(crate::ParameterError::UnknownParameter(format!(
"short-prefix lint received id={id} — longest-prefix-match should have \
routed to test.prefix.long instead"
)))
}
fn check_cert(
&self,
_cert: &x509_cert::Certificate,
_kind: crate::SubjectKind,
_now_unix: u64,
) -> crate::LintResult {
crate::LintResult::Pass
}
}
#[derive(Clone)]
struct LongPrefixLint;
impl Lint for LongPrefixLint {
fn id(&self) -> &'static str {
"test.prefix.long"
}
fn citation(&self) -> &'static str {
"fixture"
}
fn severity(&self) -> crate::Severity {
crate::Severity::Warn
}
fn scope(&self) -> crate::Scope {
crate::Scope::Certificate
}
fn applies_to(&self) -> crate::SubjectKind {
crate::SubjectKind::Leaf
}
fn set_parameter(
&mut self,
id: &str,
_value: &str,
) -> Result<(), crate::ParameterError> {
if id == "knob" {
Ok(())
} else {
Err(crate::ParameterError::UnknownParameter(id.to_owned()))
}
}
fn check_cert(
&self,
_cert: &x509_cert::Certificate,
_kind: crate::SubjectKind,
_now_unix: u64,
) -> crate::LintResult {
crate::LintResult::Pass
}
}
let lints: Vec<Box<dyn Lint>> =
vec![Box::new(ShortPrefixLint), Box::new(LongPrefixLint)];
let mut runner = LintRunner::new(lints);
let overrides = vec![ParameterOverride {
param_id: "test.prefix.long.knob".to_owned(),
value: "v".to_owned(),
}];
runner.apply_parameter_overrides(&overrides).expect(
"longest-prefix match must route to test.prefix.long not test.prefix",
);
}
#[test]
fn apply_parameter_overrides_fails_fast_before_mutation() {
use crate::LintRunner;
use std::sync::atomic::{AtomicBool, Ordering};
static APPLIED: AtomicBool = AtomicBool::new(false);
APPLIED.store(false, Ordering::SeqCst);
#[derive(Clone)]
struct ObservableLint;
impl Lint for ObservableLint {
fn id(&self) -> &'static str {
"test.observable.lint"
}
fn citation(&self) -> &'static str {
"PKIX-hy2e.3 fail-fast fixture"
}
fn severity(&self) -> crate::Severity {
crate::Severity::Warn
}
fn scope(&self) -> crate::Scope {
crate::Scope::Certificate
}
fn applies_to(&self) -> crate::SubjectKind {
crate::SubjectKind::Leaf
}
fn set_parameter(
&mut self,
_id: &str,
_value: &str,
) -> Result<(), crate::ParameterError> {
APPLIED.store(true, Ordering::SeqCst);
Ok(())
}
fn check_cert(
&self,
_cert: &x509_cert::Certificate,
_kind: crate::SubjectKind,
_now_unix: u64,
) -> crate::LintResult {
crate::LintResult::Pass
}
}
let lints: Vec<Box<dyn Lint>> = vec![Box::new(ObservableLint)];
let mut runner = LintRunner::new(lints);
let overrides = vec![
ParameterOverride {
param_id: "test.observable.lint.any".to_owned(),
value: "v".to_owned(),
},
ParameterOverride {
param_id: "no.such.lint.id.anywhere".to_owned(),
value: "v".to_owned(),
},
];
let err = runner.apply_parameter_overrides(&overrides).unwrap_err();
assert!(matches!(err, ParseError::UnknownParameterOverride { .. }));
assert!(
!APPLIED.load(Ordering::SeqCst),
"Phase 1 must surface UnknownParameterOverride before any set_parameter call"
);
}
#[derive(Clone)]
struct EchoParamLint {
value: String,
}
impl Lint for EchoParamLint {
fn id(&self) -> &'static str {
"test.echo.param"
}
fn citation(&self) -> &'static str {
"PKIX-hy2e.6 atomicity fixture"
}
fn severity(&self) -> crate::Severity {
crate::Severity::Warn
}
fn scope(&self) -> crate::Scope {
crate::Scope::Certificate
}
fn applies_to(&self) -> crate::SubjectKind {
crate::SubjectKind::Leaf
}
fn set_parameter(
&mut self,
id: &str,
value: &str,
) -> Result<(), crate::ParameterError> {
if id != "knob" {
return Err(crate::ParameterError::UnknownParameter(id.to_owned()));
}
if value == "REJECT" {
return Err(crate::ParameterError::InvalidValue {
id: id.to_owned(),
reason: "REJECT is a poison value for this fixture".to_owned(),
});
}
self.value = value.to_owned();
Ok(())
}
fn check_cert(
&self,
_cert: &x509_cert::Certificate,
_kind: crate::SubjectKind,
_now_unix: u64,
) -> crate::LintResult {
crate::LintResult::error(format!("value={}", self.value))
}
}
fn load_atomicity_fixture_cert() -> x509_cert::Certificate {
use der::Decode as _;
x509_cert::Certificate::from_der(include_bytes!(
"../../../pkix-path/tests/fixtures/policy-checks/webpki-self-signed-365d.der"
))
.expect("fixture is valid DER")
}
#[test]
fn apply_parameter_overrides_atomic_on_invalid_value_keeps_default() {
use crate::{LintRunner, LintResult, SubjectKind};
let cert = load_atomicity_fixture_cert();
let lints: Vec<Box<dyn Lint>> = vec![Box::new(EchoParamLint {
value: "default".to_string(),
})];
let mut runner = LintRunner::new(lints);
let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
match &findings[0].result {
LintResult::Error(detail) => assert_eq!(detail.as_ref(), "value=default"),
other => panic!("expected Error, got {other:?}"),
}
let overrides = vec![
ParameterOverride::new("test.echo.param.knob", "overridden"),
ParameterOverride::new("test.echo.param.knob", "REJECT"),
];
let err = runner.apply_parameter_overrides(&overrides).unwrap_err();
assert!(
matches!(err, ParseError::InvalidParameterOverride { .. }),
"REJECT must produce InvalidParameterOverride; got {err:?}"
);
let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
match &findings[0].result {
LintResult::Error(detail) => assert_eq!(
detail.as_ref(), "value=default",
"atomicity violation: the registered lint slot was mutated by the \
failed apply. Expected default value preserved; got: {detail:?}"
),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn apply_parameter_overrides_commits_on_full_success() {
use crate::{LintRunner, LintResult, SubjectKind};
let cert = load_atomicity_fixture_cert();
let lints: Vec<Box<dyn Lint>> = vec![Box::new(EchoParamLint {
value: "default".to_string(),
})];
let mut runner = LintRunner::new(lints);
runner
.apply_parameter_overrides(&[ParameterOverride::new(
"test.echo.param.knob",
"committed-value",
)])
.expect("valid value must commit");
let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
match &findings[0].result {
LintResult::Error(detail) => {
assert_eq!(
detail.as_ref(),
"value=committed-value",
"fully-successful apply must commit the new value into the \
registered slot"
);
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn apply_parameter_overrides_atomic_on_invalid_value_keeps_previous_commit() {
use crate::{LintRunner, LintResult, SubjectKind};
let cert = load_atomicity_fixture_cert();
let lints: Vec<Box<dyn Lint>> = vec![Box::new(EchoParamLint {
value: "default".to_string(),
})];
let mut runner = LintRunner::new(lints);
runner
.apply_parameter_overrides(&[ParameterOverride::new(
"test.echo.param.knob",
"first-value",
)])
.expect("first apply must succeed");
let _err = runner
.apply_parameter_overrides(&[
ParameterOverride::new("test.echo.param.knob", "second-value"),
ParameterOverride::new("test.echo.param.knob", "REJECT"),
])
.expect_err("second-apply with REJECT must fail");
let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
match &findings[0].result {
LintResult::Error(detail) => assert_eq!(
detail.as_ref(),
"value=first-value",
"atomicity: a failed batch must preserve the previously-committed \
value, not regress to default and not commit the failed batch's \
partial state; got: {detail:?}"
),
other => panic!("expected Error, got {other:?}"),
}
}
}