///|
/// A bounded comparison of two content exports, not an executable LDAP patch.
pub struct SnapshotDiff {
priv code : Int
priv result : Json
}
///|
pub fn SnapshotDiff::exit_code(self : SnapshotDiff) -> Int {
self.code
}
///|
pub fn SnapshotDiff::to_json(self : SnapshotDiff) -> Json {
self.result
}
///|
fn snapshot_attribute_key(name : String) -> String {
let parts = ascii_lower(name).split(";").map(p => p.to_owned()).to_array()
let options : Array[String] = []
for i in 1.. Map[String, SnapshotAttribute] {
let result : Map[String, SnapshotAttribute] = Map([])
if record.body is Entry(attributes) {
for a in attributes {
let key = snapshot_attribute_key(a.name)
let group = match result.get(key) {
Some(group) => group
None => {
let group : SnapshotAttribute = {
values: Map([]),
count: 0,
span: a.span,
}
result[key] = group
group
}
}
if a.value is Inline(bytes) {
// Base64 is an injective byte key, never included in the report.
let value = @base64.encode(bytes[:])
group.values[value] = group.values.get(value).unwrap_or(0) + 1
group.count += 1
group.span = { line: group.span.line, end_line: a.span.end_line, }
}
}
}
result
}
///|
fn snapshot_index(report : Report) -> Map[String, Array[Record]] {
let index : Map[String, Array[Record]] = Map([])
for r in report.document.records {
match index.get(r.dn) {
Some(records) => records.push(r)
None => index[r.dn] = [r]
}
}
index
}
///|
fn[A, B] snapshot_union(
a : Map[String, A],
b : Map[String, B],
) -> Array[String] {
let keys : Map[String, Bool] = Map([])
for key in a.keys() {
keys[key] = true
}
for key in b.keys() {
keys[key] = true
}
let sorted = keys.keys().to_array()
sorted.sort()
sorted
}
///|
fn snapshot_span(span : Span?) -> Json {
match span {
Some(s) => ToJson::to_json(s)
None => Json::null()
}
}
///|
fn snapshot_source(report : Report, length : Int, sha : String?) -> Json {
{
"byte_length": length.to_json(),
"sha256": match sha {
Some(s) => ToJson::to_json(s)
None => Json::null()
},
"status": report.status().to_json(),
"record_count": report.document.records.length().to_json(),
"diagnostics": report.diagnostics.to_json(),
}
}
///|
/// DN strings match exactly after LDIF decoding, without LDAP name matching.
/// Attribute descriptions ignore ASCII case/option order. Values are byte
/// multisets (duplicates counted); order, wrapping and Base64 spelling do not
/// matter. Host-computed SHA-256 is optional and never an authenticity proof.
/// 0 = no differences, 1 = differences, 2 = incomplete/invalid, with 2 priority.
pub fn compare_snapshots(
before : Bytes,
after : Bytes,
allow_missing_version? : Bool = false,
legacy_dn_spaces? : Bool = false,
before_sha256? : String? = None,
after_sha256? : String? = None,
) -> SnapshotDiff {
let options : Options = { allow_missing_version, deny_delete: false, }
let old = check(before, options~, legacy_dn_spaces~)
let new = check(after, options~, legacy_dn_spaces~)
let diagnostics : Array[Json] = []
let mut failed = old.exit_code() == 2 || new.exit_code() == 2
for pair in [("before", old), ("after", new)] {
let (side, report) = pair
if report.document.mode != "content" && report.document.mode != "empty" {
failed = true
diagnostics.push({
"code": "snapshot-content-only",
"side": side.to_json(),
"reason": "Comparison accepts content exports, not change plans.",
})
}
}
for pair in [("before", before_sha256), ("after", after_sha256)] {
let (side, sha) = pair
if sha is Some(s) {
if s.length() != 64 ||
!s.iter().all(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
failed = true
diagnostics.push({
"code": "snapshot-source-hash",
"side": side.to_json(),
"reason": "SHA-256 must be 64 lowercase hexadecimal characters.",
})
}
}
}
let can_compare = !failed
let items : Array[Json] = []
let counts : Map[String, Int] = {
"entry-added": 0,
"entry-removed": 0,
"attribute-added": 0,
"attribute-removed": 0,
"values-changed": 0,
}
let mut total = 0
let mut ambiguous = 0
let mut compared = 0
fn add_item(
code : String,
dn : String,
attr : String?,
old_span : Span?,
new_span : Span?,
removed : Int,
added : Int,
) {
total += 1
counts[code] = counts.get(code).unwrap_or(0) + 1
if items.length() < 200 {
items.push({
"code": code.to_json(),
"dn": dn.to_json(),
"attribute": match attr {
Some(a) => a.to_json()
None => Json::null()
},
"before_span": snapshot_span(old_span),
"after_span": snapshot_span(new_span),
"removed_value_count": removed.to_json(),
"added_value_count": added.to_json(),
})
}
}
if can_compare {
let old_index = snapshot_index(old)
let new_index = snapshot_index(new)
for dn in snapshot_union(old_index, new_index) {
let aa = old_index.get(dn).unwrap_or([])
let bb = new_index.get(dn).unwrap_or([])
if aa.length() > 1 || bb.length() > 1 {
failed = true
ambiguous += 1
if diagnostics.length() < 100 {
diagnostics.push({
"code": "snapshot-duplicate-dn",
"dn": dn.to_json(),
"before_count": aa.length().to_json(),
"after_count": bb.length().to_json(),
"before_span": if aa.is_empty() {
Json::null()
} else {
ToJson::to_json(aa[0].span)
},
"after_span": if bb.is_empty() {
Json::null()
} else {
ToJson::to_json(bb[0].span)
},
"reason": "Repeated exact DN is ambiguous; this key was excluded from comparison.",
})
}
continue
}
compared += 1
if aa.is_empty() {
add_item("entry-added", dn, None, None, Some(bb[0].span), 0, 0)
} else if bb.is_empty() {
add_item("entry-removed", dn, None, Some(aa[0].span), None, 0, 0)
} else {
let a_attrs = snapshot_attributes(aa[0])
let b_attrs = snapshot_attributes(bb[0])
for attr in snapshot_union(a_attrs, b_attrs) {
match (a_attrs.get(attr), b_attrs.get(attr)) {
(None, Some(b)) =>
add_item(
"attribute-added",
dn,
Some(attr),
None,
Some(b.span),
0,
b.count,
)
(Some(a), None) =>
add_item(
"attribute-removed",
dn,
Some(attr),
Some(a.span),
None,
a.count,
0,
)
(Some(a), Some(b)) => {
let mut removed = 0
let mut added = 0
for value, count in a.values {
let delta = count - b.values.get(value).unwrap_or(0)
if delta > 0 {
removed += delta
}
}
for value, count in b.values {
let delta = count - a.values.get(value).unwrap_or(0)
if delta > 0 {
added += delta
}
}
if removed > 0 || added > 0 {
add_item(
"values-changed",
dn,
Some(attr),
Some(a.span),
Some(b.span),
removed,
added,
)
}
}
_ => ()
}
}
}
}
}
let code = if failed { 2 } else if total > 0 { 1 } else { 0 }
{
code,
result: {
"tool": "MoonLDIF",
"version": version().to_json(),
"kind": "snapshot-diff",
"report_schema_version": 1,
"exit_code": code.to_json(),
"status": (if failed { "incomplete" } else { "complete" }).to_json(),
"analysis_complete": (!failed).to_json(),
"comparison_performed": can_compare.to_json(),
"matching": "Exact decoded DN string; ASCII-insensitive attribute descriptions; byte multiset values",
"scope": "Export comparison only; not LDAP semantic equality, rename detection or an executable patch. Reports contain DNs, not attribute values.",
"options": {
"allow_missing_version": allow_missing_version.to_json(),
"legacy_dn_spaces": legacy_dn_spaces.to_json(),
},
"before": snapshot_source(old, before.length(), before_sha256),
"after": snapshot_source(new, after.length(), after_sha256),
"diagnostics": diagnostics.to_json(),
"diagnostics_truncated": (ambiguous > 100).to_json(),
"ambiguous_dn_count": ambiguous.to_json(),
"compared_dn_count": compared.to_json(),
"counts": counts.to_json(),
"total_changes": total.to_json(),
"reported_changes": items.length().to_json(),
"truncated": (total > items.length()).to_json(),
"changes": items.to_json(),
},
}
}
///|
/// Human-readable reports retain all JSON fields without raw attribute bytes.
pub fn SnapshotDiff::to_text(self : SnapshotDiff) -> String {
"MoonLDIF snapshot comparison; exit " +
self.code.to_string() +
"\n" +
self.result.stringify(indent=2) +
"\n"
}
///|
fn snapshot_field(json : Json, key : String) -> Json {
match json {
Object(fields) => fields.get(key).unwrap_or(Json::null())
_ => Json::null()
}
}
///|
fn snapshot_display(json : Json) -> String {
match json {
String(s) => s
Null => "-"
_ => json.stringify()
}
}
///|
/// Human-readable summary and locations; all input-derived strings are escaped.
pub fn SnapshotDiff::to_markdown(self : SnapshotDiff) -> String {
let out = StringBuilder()
out.write_string(
"# MoonLDIF snapshot comparison\n\nExit: " + self.code.to_string() + "\n\n",
)
out.write_string(
"Content exports only. Exact DN strings and byte multiset values; not server equality or an executable patch. Reports contain DNs. Truncated findings are explicitly counted.\n\n",
)
out.write_string("Version: " + version() + "\n\n")
for
key in [
"analysis_complete", "comparison_performed", "options", "counts", "total_changes",
"reported_changes", "truncated", "ambiguous_dn_count", "diagnostics_truncated",
] {
out.write_string(
"- " +
markdown_escape(key) +
": " +
markdown_escape(snapshot_display(snapshot_field(self.result, key))) +
"\n",
)
}
for side in ["before", "after"] {
out.write_string("\n## " + side + " source\n\n")
let source = snapshot_field(self.result, side)
for
key in ["byte_length", "sha256", "status", "record_count", "diagnostics"] {
out.write_string(
"- " +
markdown_escape(key) +
": " +
markdown_escape(snapshot_display(snapshot_field(source, key))) +
"\n",
)
}
}
out.write_string(
"\n## Comparison diagnostics\n\n" +
markdown_escape(snapshot_field(self.result, "diagnostics").stringify()) +
"\n\n## Differences\n\n",
)
out.write_string(
"| Change | DN | Attribute | Before lines | After lines | Removed values | Added values |\n|---|---|---|---|---|---|---|\n",
)
if snapshot_field(self.result, "changes") is Array(items) {
for item in items {
out.write_string("|")
for
key in [
"code", "dn", "attribute", "before_span", "after_span", "removed_value_count",
"added_value_count",
] {
out.write_string(
" " +
markdown_escape(snapshot_display(snapshot_field(item, key))) +
" |",
)
}
out.write_string("\n")
}
}
out.to_string()
}