///|
/// A compact data-retention manifest parsed by CapsuleTrace.
///
/// Records:
///
/// - `day: `
/// - `rule: id | purpose | data-kind | max-days | anonymize-after | consent/no-consent | regions | internal-only/external-ok | owner | note`
/// - `capsule: id | subject | purpose | data-kind | collected-day | last-used-day | consent | sensitivity | region | encrypted/plain | sharing | note`
pub(all) enum RetentionRecordKind {
DayRecord
RuleRecord
CapsuleRecord
UnknownRetentionRecord
} derive(Eq, Debug)
///|
pub(all) struct RetentionParseIssue {
line : Int
record : String
message : String
} derive(Eq, Debug)
///|
pub(all) struct RetentionManifest {
today : Int
rules : Array[RetentionRule]
capsules : Array[DataCapsule]
issues : Array[RetentionParseIssue]
} derive(Eq, Debug)
///|
pub fn RetentionRecordKind::label(self : RetentionRecordKind) -> String {
match self {
DayRecord => "day"
RuleRecord => "rule"
CapsuleRecord => "capsule"
UnknownRetentionRecord => "unknown"
}
}
///|
pub fn parse_retention_manifest(raw : StringView) -> RetentionManifest {
let mut today = 0
let rules : Array[RetentionRule] = []
let capsules : Array[DataCapsule] = []
let issues : Array[RetentionParseIssue] = []
for line_number, raw_line in raw.split("\n").iter2() {
let line = raw_line.trim()
if !line.is_empty() && !line.has_prefix("#") {
match line.split_once(":") {
Some((record_view, value_view)) => {
let record = record_view.trim().to_lower().to_owned()
let value = value_view.trim()
match record {
"day" =>
match parse_positive_int(value) {
Some(parsed) => today = parsed
None =>
issues.push(
parse_issue(
line_number + 1,
"day",
"day must be a non-negative integer",
),
)
}
"rule" =>
match parse_rule_record(value, line_number + 1, issues) {
Some(rule) => rules.push(rule)
None => ()
}
"capsule" =>
match parse_capsule_record(value, line_number + 1, issues) {
Some(capsule) => capsules.push(capsule)
None => ()
}
_ =>
issues.push(
parse_issue(
line_number + 1,
record,
"record must be day, rule, capsule, or comment",
),
)
}
}
None =>
issues.push(
parse_issue(
line_number + 1,
"",
"manifest line must use ': '",
),
)
}
}
}
{ today, rules, capsules, issues }
}
///|
pub fn plan_manifest(raw : StringView) -> RetentionPlan {
let manifest = parse_retention_manifest(raw)
plan_retention(manifest.capsules, manifest.rules, manifest.today)
}
///|
pub fn RetentionManifest::is_parseable(self : RetentionManifest) -> Bool {
self.today > 0 && self.issues.is_empty()
}
///|
pub fn RetentionManifest::to_markdown(self : RetentionManifest) -> String {
let lines : Array[String] = []
lines.push("# CapsuleTrace Manifest Parse")
lines.push("")
lines.push("- Day: " + self.today.to_string())
lines.push("- Rules: " + self.rules.length().to_string())
lines.push("- Capsules: " + self.capsules.length().to_string())
lines.push("- Issues: " + self.issues.length().to_string())
lines.push("")
lines.push("| Line | Record | Message |")
lines.push("| --- | --- | --- |")
if self.issues.is_empty() {
lines.push("| - | info | no parse issues |")
} else {
for issue in self.issues {
lines.push(
"| " +
issue.line.to_string() +
" | " +
escape_cell(issue.record) +
" | " +
escape_cell(issue.message) +
" |",
)
}
}
lines.join("\n")
}
///|
pub fn sample_retention_manifest() -> String {
[
"# CapsuleTrace data-retention manifest", "day: 220", "rule: analytics.behavior | analytics | behavior | 180 | 30 | consent | eu,cn,us | internal-only | privacy-analytics | Aggregate visitor behavior quickly.",
"rule: support.ticket | support | ticket | 365 | 120 | no-consent | cn,sg | external-ok | support-ops | Support vendors are allowed processors.",
"rule: billing.invoice | billing | invoice | 2555 | 2555 | no-consent | cn | internal-only | finance | Billing records stay local.",
"capsule: cap.active-analytics | visitor | analytics | behavior | 200 | 216 | granted | sensitive | cn | encrypted | internal | Active telemetry.",
"capsule: cap.idle-analytics | visitor | analytics | behavior | 120 | 150 | granted | sensitive | cn | encrypted | internal | Idle telemetry.",
"capsule: cap.withdrawn | visitor | analytics | behavior | 160 | 190 | withdrawn | sensitive | cn | encrypted | internal | Withdrawn consent.",
"capsule: cap.unencrypted-ticket | customer | support | ticket | 40 | 205 | not-required | confidential | sg | plain | processor | Missing encryption.",
"capsule: cap.region-mismatch | buyer | billing | invoice | 20 | 210 | not-required | restricted | us | encrypted | internal | Invoice outside region.",
].join("\n")
}
///|
fn parse_rule_record(
value : StringView,
line : Int,
issues : Array[RetentionParseIssue],
) -> RetentionRule? {
let fields = split_fields(value)
if fields.length() < 10 {
issues.push(
parse_issue(
line, "rule", "expected 10 fields: id | purpose | data-kind | max-days | anonymize-after | consent/no-consent | regions | sharing | owner | note",
),
)
None
} else {
match
(
parse_positive_int(fields[3]),
parse_positive_int(fields[4]),
parse_consent_required(fields[5]),
parse_rule_sharing(fields[7]),
) {
(
Some(max_keep_days),
Some(anonymize_after_days),
Some(requires_consent),
Some(allow_external_sharing),
) =>
Some(
retention_rule(
fields[0],
fields[1],
fields[2],
max_keep_days,
anonymize_after_days,
requires_consent,
split_csv(fields[6]),
allow_external_sharing,
fields[8],
fields[9],
),
)
(None, _, _, _) => {
issues.push(
parse_issue(line, "rule", "max-days must be a non-negative integer"),
)
None
}
(_, None, _, _) => {
issues.push(
parse_issue(
line, "rule", "anonymize-after must be a non-negative integer",
),
)
None
}
(_, _, None, _) => {
issues.push(
parse_issue(
line, "rule", "consent field must be consent or no-consent",
),
)
None
}
(_, _, _, None) => {
issues.push(
parse_issue(
line, "rule", "sharing field must be internal-only or external-ok",
),
)
None
}
}
}
}
///|
fn parse_capsule_record(
value : StringView,
line : Int,
issues : Array[RetentionParseIssue],
) -> DataCapsule? {
let fields = split_fields(value)
if fields.length() < 12 {
issues.push(
parse_issue(
line, "capsule", "expected 12 fields: id | subject | purpose | data-kind | collected | last-used | consent | sensitivity | region | encrypted/plain | sharing | note",
),
)
None
} else {
match
(
parse_positive_int(fields[4]),
parse_positive_int(fields[5]),
parse_consent_state(fields[6]),
parse_sensitivity(fields[7]),
parse_encryption(fields[9]),
parse_sharing_mode(fields[10]),
) {
(
Some(collected_day),
Some(last_used_day),
Some(consent),
Some(sensitivity),
Some(encrypted),
Some(sharing),
) =>
Some(
data_capsule(
fields[0],
fields[1],
fields[2],
fields[3],
collected_day,
last_used_day,
consent,
sensitivity,
fields[8],
encrypted,
sharing,
fields[11],
),
)
(None, _, _, _, _, _) => {
issues.push(
parse_issue(
line, "capsule", "collected day must be a non-negative integer",
),
)
None
}
(_, None, _, _, _, _) => {
issues.push(
parse_issue(
line, "capsule", "last-used day must be a non-negative integer",
),
)
None
}
(_, _, None, _, _, _) => {
issues.push(
parse_issue(
line, "capsule", "consent must be granted, denied, expired, withdrawn, or not-required",
),
)
None
}
(_, _, _, None, _, _) => {
issues.push(
parse_issue(
line, "capsule", "sensitivity must be public, internal, confidential, sensitive, or restricted",
),
)
None
}
(_, _, _, _, None, _) => {
issues.push(
parse_issue(line, "capsule", "encryption must be encrypted or plain"),
)
None
}
(_, _, _, _, _, None) => {
issues.push(
parse_issue(
line, "capsule", "sharing must be internal, processor, or public",
),
)
None
}
}
}
}
///|
fn parse_positive_int(value : StringView) -> Int? {
let text = value.trim().to_owned()
if text.is_empty() {
None
} else {
let mut result = 0
for index in 0.. 57 {
return None
}
result = result * 10 + (code - 48)
}
Some(result)
}
}
///|
fn parse_consent_required(value : String) -> Bool? {
match normalized(value) {
"consent" => Some(true)
"requires-consent" => Some(true)
"no-consent" => Some(false)
"not-required" => Some(false)
_ => None
}
}
///|
fn parse_rule_sharing(value : String) -> Bool? {
match normalized(value) {
"internal-only" => Some(false)
"external-ok" => Some(true)
"processors-ok" => Some(true)
_ => None
}
}
///|
fn parse_consent_state(value : String) -> ConsentState? {
match normalized(value) {
"granted" => Some(ConsentGranted)
"denied" => Some(ConsentDenied)
"expired" => Some(ConsentExpired)
"withdrawn" => Some(ConsentWithdrawn)
"not-required" => Some(ConsentNotRequired)
_ => None
}
}
///|
fn parse_sensitivity(value : String) -> CapsuleSensitivity? {
match normalized(value) {
"public" => Some(PublicData)
"internal" => Some(InternalData)
"confidential" => Some(ConfidentialData)
"sensitive" => Some(SensitiveData)
"restricted" => Some(RestrictedData)
_ => None
}
}
///|
fn parse_encryption(value : String) -> Bool? {
match normalized(value) {
"encrypted" => Some(true)
"plain" => Some(false)
"unencrypted" => Some(false)
_ => None
}
}
///|
fn parse_sharing_mode(value : String) -> SharingMode? {
match normalized(value) {
"internal" => Some(InternalOnly)
"processor" => Some(ProcessorShared)
"processors" => Some(ProcessorShared)
"public" => Some(PublicShared)
_ => None
}
}
///|
fn split_fields(value : StringView) -> Array[String] {
let fields : Array[String] = []
for field in value.split("|") {
fields.push(field.trim().to_owned())
}
fields
}
///|
fn split_csv(value : String) -> Array[String] {
let items : Array[String] = []
for raw_item in value.split(",") {
let item = raw_item.trim()
if !item.is_empty() {
items.push(item.to_owned())
}
}
items
}
///|
fn normalized(value : String) -> String {
value.trim().to_lower().to_owned()
}
///|
fn parse_issue(
line : Int,
record : String,
message : String,
) -> RetentionParseIssue {
{ line, record, message }
}
///|
fn escape_cell(value : String) -> String {
value.replace_all(old="|", new="\\|").replace_all(old="\n", new=" ")
}