///|
pub suberror ProfileError {
InvalidProfile(String)
}
///|
/// Validated, immutable rules. Construct only through parse_profile.
pub struct Profile {
priv options : Options
priv risk : RiskPolicy
priv legacy : Bool
priv ignored : Array[String]
priv limits : Array[Int?]
}
///|
fn profile_keys(
value : Json,
allowed : Array[String],
) -> Unit raise ProfileError {
match value {
Object(fields) =>
for key, _ in fields {
if !allowed.contains(key) {
raise InvalidProfile("Unknown configuration field.")
}
}
_ => raise InvalidProfile("Expected a configuration object.")
}
}
///|
fn profile_section(value : Json, key : String) -> Json {
if value is Object(fields) {
fields.get(key).unwrap_or(Json::object(Map([])))
} else {
Json::null()
}
}
///|
fn profile_bool(value : Json, key : String) -> Bool raise ProfileError {
if value is Object(fields) {
match fields.get(key) {
None => false
Some(True) => true
Some(False) => false
_ => raise InvalidProfile("Configuration flags must be booleans.")
}
} else {
raise InvalidProfile("Expected a configuration object.")
}
}
///|
fn profile_limit(value : Json, key : String) -> Int? raise ProfileError {
if value is Object(fields) {
match fields.get(key) {
None => None
Some(Number(n, repr~)) => {
if repr is Some(raw) {
if !raw.iter().all(c => c >= '0' && c <= '9') {
raise InvalidProfile(
"Use unsigned decimal integer notation for limits and version.",
)
}
}
if n < 0 || n > 2147483647 || n != n.to_int().to_double() {
raise InvalidProfile("Limits must be integers from 0 to 2147483647.")
}
Some(n.to_int())
}
_ => raise InvalidProfile("Limits must be integers from 0 to 2147483647.")
}
} else {
raise InvalidProfile("Expected a limits object.")
}
}
///|
/// Run after the bounded standard parser. Decoding each key catches escaped duplicates.
fn profile_unique_keys(text : String) -> Unit raise ProfileError {
let objects : Array[Map[String, Bool]] = []
let mut i = 0
let mut depth = 0
while i < text.length() {
if text[i] == '{' || text[i] == '[' {
depth += 1
if depth > 16 {
raise InvalidProfile("Configuration nesting exceeds 16.")
}
if text[i] == '{' {
objects.push(Map([]))
}
i += 1
} else if text[i] == '}' || text[i] == ']' {
depth -= 1
if text[i] == '}' {
ignore(objects.pop())
}
i += 1
} else if text[i] == '"' {
let start = i
i += 1
while i < text.length() && text[i] != '"' {
if text[i] == '\\' {
i += 1
}
i += 1
}
i += 1
let end = i
while i < text.length() &&
(
text[i] == ' ' ||
text[i] == '\t' ||
text[i] == '\r' ||
text[i] == '\n'
) {
i += 1
}
if i < text.length() && text[i] == ':' && !objects.is_empty() {
let key = @json.parse(text[start:end].to_owned()) catch {
_ => raise InvalidProfile("Invalid configuration key.")
}
if key is String(s) {
let seen = objects[objects.length() - 1]
if seen.contains(s) {
raise InvalidProfile("Duplicate configuration key.")
}
seen[s] = true
}
}
} else if text[i] == '-' || (text[i] >= '0' && text[i] <= '9') {
while i < text.length() &&
text[i] != ',' &&
text[i] != '}' &&
text[i] != ']' &&
text[i] != ' ' &&
text[i] != '\t' &&
text[i] != '\r' &&
text[i] != '\n' {
if text[i] < '0' || text[i] > '9' {
raise InvalidProfile(
"Use unsigned decimal integer notation for limits and version.",
)
}
i += 1
}
} else {
i += 1
}
}
}
///|
pub fn parse_profile(data : Bytes) -> Profile raise ProfileError {
if data.length() > 65536 {
raise InvalidProfile("Configuration exceeds 64 KiB.")
}
let text = @utf8.decode(data) catch {
_ => raise InvalidProfile("Configuration must be UTF-8.")
}
profile_unique_keys(text)
let raw = @json.parse(text) catch {
_ => raise InvalidProfile("Invalid configuration JSON.")
}
profile_keys(raw, ["profile_version", "common", "review", "compare"])
if profile_limit(raw, "profile_version") != Some(1) {
raise InvalidProfile("Expected profile_version 1.")
}
let common = profile_section(raw, "common")
let review = profile_section(raw, "review")
let compare = profile_section(raw, "compare")
let limits = profile_section(review, "limits")
profile_keys(common, ["allow_missing_version", "legacy_dn_spaces"])
profile_keys(review, ["deny_delete", "deny_clear", "deny_rename", "limits"])
profile_keys(compare, ["ignored_attributes"])
profile_keys(limits, [
"max_change_records", "max_delete_records", "max_clear_operations",
])
let names : Map[String, Bool] = Map([])
if compare is Object(fields) {
match fields.get("ignored_attributes") {
None => ()
Some(Array(attrs)) => {
if attrs.length() > 64 {
raise InvalidProfile("At most 64 ignored attributes.")
}
for attr in attrs {
if attr is String(s) {
if s.length() > 256 || !valid_attribute(s) || ascii_lower(s) == "dn" {
raise InvalidProfile(
"Invalid ignored attribute description; DN cannot be excluded.",
)
}
names[snapshot_attribute_key(s)] = true
} else {
raise InvalidProfile("Ignored attributes must be strings.")
}
}
}
_ => raise InvalidProfile("Ignored attributes must be an array.")
}
}
let ignored = names.keys().to_array()
ignored.sort()
{
options: {
allow_missing_version: profile_bool(common, "allow_missing_version"),
deny_delete: profile_bool(review, "deny_delete"),
},
risk: {
deny_clear: profile_bool(review, "deny_clear"),
deny_rename: profile_bool(review, "deny_rename"),
},
legacy: profile_bool(common, "legacy_dn_spaces"),
ignored,
limits: [
profile_limit(limits, "max_change_records"),
profile_limit(limits, "max_delete_records"),
profile_limit(limits, "max_clear_operations"),
],
}
}
///|
pub fn Profile::to_json(self : Profile) -> Json {
let limits : Map[String, Json] = Map([])
for
i, key in [
"max_change_records", "max_delete_records", "max_clear_operations",
] {
if self.limits[i] is Some(n) {
limits[key] = n.to_json()
}
}
{
"profile_version": 1,
"common": {
"allow_missing_version": self.options.allow_missing_version.to_json(),
"legacy_dn_spaces": self.legacy.to_json(),
},
"review": {
"deny_delete": self.options.deny_delete.to_json(),
"deny_clear": self.risk.deny_clear.to_json(),
"deny_rename": self.risk.deny_rename.to_json(),
"limits": Json::object(limits),
},
"compare": { "ignored_attributes": self.ignored.to_json() },
}
}
///|
/// Canonical UTF-8 input for the platform's effective-configuration SHA-256.
pub fn Profile::canonical(self : Profile) -> String {
self.to_json().stringify()
}
///|
/// Only rules applied by this mode participate in its effective fingerprint.
pub fn Profile::effective_json(
self : Profile,
section : String,
) -> Json raise ProfileError {
if section != "review" && section != "compare" {
raise InvalidProfile("Expected review or compare configuration section.")
}
let config = self.to_json()
let fields : Map[String, Json] = Map([])
fields["profile_version"] = (1).to_json()
fields["common"] = snapshot_field(config, "common")
fields[section] = snapshot_field(config, section)
Json::object(fields)
}
///|
fn profile_evaluate(report : Report, profile : Profile) -> Json {
let counts = [0, 0, 0]
let first : Array[Span?] = [None, None, None]
fn count(i : Int, span : Span) -> Unit {
counts[i] += 1
if profile.limits[i] is Some(limit) {
if counts[i] > limit && first[i] == None {
first[i] = Some(span)
}
}
}
for r in report.document.records {
match r.body {
Entry(_) => ()
_ => count(0, r.span)
}
match r.body {
Delete => count(1, r.span)
Modify(mods) =>
for m in mods {
if (m.operation == "delete" || m.operation == "replace") &&
m.values.is_empty() {
count(2, m.span)
}
}
_ => ()
}
}
let complete = report.exit_code() != 2
let applicable = report.document.mode != "content"
let metrics : Array[Json] = []
for
i, key in [
"max_change_records", "max_delete_records", "max_clear_operations",
] {
let code = [
"change-record-limit", "delete-record-limit", "clear-operation-limit",
][i]
metrics.push({
"rule": key.to_json(),
"code": code.to_json(),
"actual": counts[i].to_json(),
"limit": profile.limits[i].to_json(),
"first_exceeded_span": first[i].to_json(),
})
if first[i] is Some(span) {
diagnose(
report.diagnostics,
code,
"policy",
span,
"Observed " +
counts[i].to_string() +
" operations; configured maximum " +
profile.limits[i].unwrap().to_string() +
". Counts cover all parsed records per file.",
)
}
}
if !applicable && profile.limits.any(n => n != None) {
diagnose(
report.diagnostics,
"profile-limits-not-applicable",
"error",
single_line(1),
"Change limits require a change plan, not a content export.",
)
}
{
"scope": "per_file",
"applicable": applicable.to_json(),
"counts_complete": (complete && applicable).to_json(),
"counts_are_lower_bounds": (!complete).to_json(),
"metrics": metrics.to_json(),
}
}
///|
pub fn check_with_profile(data : Bytes, profile : Profile) -> Report {
let r = check(
data,
options=profile.options,
risk_policy=profile.risk,
legacy_dn_spaces=profile.legacy,
)
ignore(profile_evaluate(r, profile))
r
}
///|
fn profile_metadata(
profile : Profile,
section : String,
source_sha : String,
effective_sha : String,
) -> Json raise QueryError {
for hash in [source_sha, effective_sha] {
if hash != "" &&
(
hash.length() != 64 ||
!hash.iter().all(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))
) {
raise InvalidQuery("Invalid configuration SHA-256.")
}
}
let config = profile.effective_json(section) catch {
_ => raise InvalidQuery("Invalid configuration section.")
}
{
"applied_section": section.to_json(),
"source_sha256": source_sha.to_json(),
"effective_sha256": effective_sha.to_json(),
"effective": config,
}
}