From 0249594ad5bc7d9bbbe888d697b5dadeb096fc6b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:09:57 +0000 Subject: [PATCH] dir-core: identity rules, witness evidence, and the ruler fold Hybrid identity correspondence across AD, Entra and Exchange Online, as numbers, without merging nodes. - identity: IdentityRule (declaration) + CompiledRule (numeric slots). Encodings MsGuidBytes16 / Base64MsGuidBytes16 / Labeled / GuidText, strict (renders back or refused), absent / malformed / nil kept apart. Rules: ConsistencyGuid -> ImmutableId, objectGUID -> ImmutableId (separate profile, no fallback), backsync -> Entra id, Entra id -> EXO ExternalDirectoryObjectId. - edge: EdgeEvidence codes 2-4; code 1 keeps its objectGUID meaning. - schema: SchemaFamily::ExchangeOnline = 3 (additive). - correspond: Lanes (boundary decode, once), sort-merge join fold (puts unaligned observations onto the ruler, cold), and fold_aligned: slot i is the same user in every column, so each witness is a position-wise compare into bit planes. Ambiguous or contested counterparts never reach the ruler. - ogar-az sync_edges documented as the objectGUID profile only. - dir-sim SourceAnchor::from_immutable_id reuses the dir-core codec. Tests: F1-F14 through the real ogar-ad / ogar-az encoders, plus a 3,000-world randomized comparison with an independent reference that also checks the ruler fold against the join slot by slot. Bench (1,048,576 slots, release, one thread, Xeon 2.1 GHz): ruler fold 8.2 ms median, 0 allocations; join fold 200 ms; Guid128 equality 1.2 ms; HashMap lookup baseline 95 ms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-az/src/lib.rs | 6 + crates/ogar-az/tests/correspond.rs | 950 ++++++++++++++++++ .../examples/identity_fold_bench.rs | 382 +++++++ crates/ogar-dir-core/src/correspond.rs | 857 ++++++++++++++++ crates/ogar-dir-core/src/edge.rs | 66 +- crates/ogar-dir-core/src/identity.rs | 428 ++++++++ crates/ogar-dir-core/src/label.rs | 8 +- crates/ogar-dir-core/src/lib.rs | 3 + crates/ogar-dir-core/src/record.rs | 2 +- crates/ogar-dir-core/src/schema.rs | 3 + crates/ogar-dir-sim/src/identity.rs | 8 +- 11 files changed, 2703 insertions(+), 10 deletions(-) create mode 100644 crates/ogar-az/tests/correspond.rs create mode 100644 crates/ogar-dir-core/examples/identity_fold_bench.rs create mode 100644 crates/ogar-dir-core/src/correspond.rs create mode 100644 crates/ogar-dir-core/src/identity.rs diff --git a/crates/ogar-az/src/lib.rs b/crates/ogar-az/src/lib.rs index 9cf4f9f6..d8ced71b 100644 --- a/crates/ogar-az/src/lib.rs +++ b/crates/ogar-az/src/lib.rs @@ -514,6 +514,12 @@ pub fn attr_multi<'p>(rec: &DirRecord, pool: &'p ValuePool, name: &str) -> Optio /// `SynchronizesTo` edges for AZ records whose `onPremisesImmutableId` /// base64-decodes to the `objectGUID` of an **observed** AD object. /// +/// This is the `objectGUID` anchor profile only. A deployment whose source +/// anchor is `mS-DS-ConsistencyGuid` (which may differ from `objectGUID`) +/// gets no edge here; use `ogar_dir_core::correspond` with +/// `identity::CONSISTENCY_GUID_TO_IMMUTABLE_ID`, which also reads the +/// backsync and Exchange Online witnesses. +/// /// This is evidence, not a decision: anchors that are not a 16-byte GUID /// (e.g. custom source anchors) yield no edge, and an immutable id matching no /// observed AD object yields no dangling edge. The two nodes stay distinct. diff --git a/crates/ogar-az/tests/correspond.rs b/crates/ogar-az/tests/correspond.rs new file mode 100644 index 00000000..965e215a --- /dev/null +++ b/crates/ogar-az/tests/correspond.rs @@ -0,0 +1,950 @@ +//! Hybrid identity correspondence: falsifiers F1-F14 and a randomized +//! comparison with an independent reference. +//! +//! Every fixture goes through the real encoders (`ogar_ad::encode`, +//! `ogar_az::encode_user`), so the fold sees the records production builds. +//! The reference ([`oracle`]) reads the fixture specification, not the +//! records, and uses plain loops over the intended ids; it never calls the +//! fold, the index or the rule codecs. + +use ogar_ad::AdEntry; +use ogar_dir_core::correspond::{ + AdStatus, Binding, Index, Lanes, Output, Planes, Profile, Ruler, Source, flag, fold, + fold_aligned, +}; +use ogar_dir_core::identity::{ + BACKSYNC_TO_ENTRA_ID, CONSISTENCY_GUID_TO_IMMUTABLE_ID, ENTRA_ID_TO_EXO_EXTERNAL_ID, + OBJECT_GUID_TO_IMMUTABLE_ID, +}; +use ogar_dir_core::{ + AttrDef, AttrKind, DirRecord, EdgeEvidence, Guid128, IdentityRule, OuDictionary, SchemaFamily, + SchemaId, ValuePool, base64, +}; +use serde_json::{Map, Value, json}; + +const EXO_SCHEMA: &[AttrDef] = &[AttrDef { + name: "ExternalDirectoryObjectId", + slot: 0, + kind: AttrKind::Guid, + since: 1, +}]; + +fn g(n: u64) -> Guid128 { + // A spread-out, non-nil id per number; the high bits make the textual + // and mixed-endian orders differ. + let mut b = [0u8; 16]; + b[..8].copy_from_slice(&(n.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1).to_be_bytes()); + b[8..].copy_from_slice(&n.to_be_bytes()); + Guid128(b) +} + +const DOMAIN: u64 = 9_001; +const TENANT: u64 = 9_002; +const OTHER_DOMAIN: u64 = 9_003; +const OTHER_TENANT: u64 = 9_004; + +/// A value as the fixture means it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Wire { + Absent, + Id(Guid128), + /// Present but not a value of the encoding. + Malformed, + /// The all-zero id, spelled correctly. + Nil, +} + +#[derive(Clone, Debug)] +struct Ad { + guid: Guid128, + domain: Guid128, + cg: Wire, + backsync: Wire, + group: bool, + at: i64, +} + +#[derive(Clone, Debug)] +struct Entra { + id: Guid128, + tenant: Guid128, + immutable: Wire, + upn: Option<&'static str>, + at: i64, +} + +#[derive(Clone, Debug)] +struct Exo { + guid: Guid128, + tenant: Guid128, + external: Wire, +} + +#[derive(Clone, Debug, Default)] +struct World { + ad: Vec, + entra: Vec, + exo: Vec, +} + +fn ad(guid: Guid128, cg: Wire, backsync: Wire) -> Ad { + Ad { + guid, + domain: g(DOMAIN), + cg, + backsync, + group: false, + at: 1_000, + } +} + +fn entra(id: Guid128, immutable: Wire) -> Entra { + Entra { + id, + tenant: g(TENANT), + immutable, + upn: None, + at: 1_000, + } +} + +// ---- the fixture as the encoders see it ----------------------------------- + +fn ad_entry(a: &Ad) -> AdEntry { + let mut attrs = vec![ + ("objectGUID".to_string(), a.guid.to_ms_bytes().to_vec()), + ( + "objectClass".to_string(), + if a.group { + b"group".to_vec() + } else { + b"user".to_vec() + }, + ), + ]; + match a.cg { + Wire::Absent => {} + Wire::Id(id) => attrs.push(("mS-DS-ConsistencyGuid".into(), id.to_ms_bytes().to_vec())), + Wire::Malformed => attrs.push(("mS-DS-ConsistencyGuid".into(), vec![7u8; 15])), + Wire::Nil => attrs.push(("mS-DS-ConsistencyGuid".into(), vec![0u8; 16])), + } + let label = if a.group { "Group_" } else { "User_" }; + match a.backsync { + Wire::Absent => {} + Wire::Id(id) => attrs.push(( + "msDS-ExternalDirectoryObjectId".into(), + format!("{label}{id}").into_bytes(), + )), + Wire::Malformed => attrs.push(( + "msDS-ExternalDirectoryObjectId".into(), + b"User_not-a-guid".to_vec(), + )), + Wire::Nil => attrs.push(( + "msDS-ExternalDirectoryObjectId".into(), + format!("{label}{}", Guid128::NIL).into_bytes(), + )), + } + AdEntry { + dn: String::new(), + attrs, + } +} + +fn entra_obj(e: &Entra) -> Map { + let mut m = Map::new(); + m.insert("id".into(), json!(e.id.to_string())); + let imm = match e.immutable { + Wire::Absent => None, + Wire::Id(id) => Some(base64::encode(&id.to_ms_bytes())), + Wire::Malformed => Some("not base64!".to_string()), + Wire::Nil => Some(base64::encode(&[0u8; 16])), + }; + if let Some(s) = imm { + m.insert("onPremisesImmutableId".into(), json!(s)); + } + if let Some(u) = e.upn { + m.insert("userPrincipalName".into(), json!(u)); + m.insert("mail".into(), json!(u)); + } + m +} + +struct Encoded { + ad: Vec, + ad_pool: ValuePool, + entra: Vec, + entra_pool: ValuePool, + exo: Vec, + exo_pool: ValuePool, + /// AD entries the encoder refused, by fixture index. + ad_refused: Vec, +} + +fn encode(w: &World) -> Encoded { + let (mut dict, mut ad_pool) = (OuDictionary::new(), ValuePool::new()); + let mut ad_out = Vec::new(); + let mut ad_refused = Vec::new(); + for (i, a) in w.ad.iter().enumerate() { + match ogar_ad::encode(&ad_entry(a), a.domain, &mut dict, &mut ad_pool, a.at) { + Ok(e) => ad_out.push(e.record), + Err(_) => ad_refused.push(i), + } + } + let mut entra_pool = ValuePool::new(); + let entra_out = w + .entra + .iter() + .enumerate() + .map(|(i, e)| { + ogar_az::encode_user(&entra_obj(e), i, e.tenant, &mut dict, &mut entra_pool, e.at) + .unwrap() + }) + .collect(); + let exo_schema = SchemaId { + family: SchemaFamily::ExchangeOnline, + version: 1, + }; + let exo_out = w + .exo + .iter() + .map(|x| { + let mut r = DirRecord::new(exo_schema, 1, x.guid, x.tenant, 1_000); + if let Wire::Id(id) = x.external { + r.set_guid(0, id).unwrap(); + } + r + }) + .collect(); + Encoded { + ad: ad_out, + ad_pool, + entra: entra_out, + entra_pool, + exo: exo_out, + exo_pool: ValuePool::new(), + ad_refused, + } +} + +fn profile(anchor: IdentityRule) -> Profile { + Profile { + anchor: anchor + .compile(ogar_ad::SCHEMA_V1, ogar_az::SCHEMA_V1) + .unwrap(), + backsync: BACKSYNC_TO_ENTRA_ID + .compile(ogar_ad::SCHEMA_V1, ogar_az::SCHEMA_V1) + .unwrap(), + cloud: ENTRA_ID_TO_EXO_EXTERNAL_ID + .compile(ogar_az::SCHEMA_V1, EXO_SCHEMA) + .unwrap(), + max_skew_ms: 60_000, + } +} + +fn bindings() -> Vec { + vec![Binding { + ad_domain: g(DOMAIN), + tenant: g(TENANT), + }] +} + +struct Run { + lanes: Lanes, + out: Output, +} + +fn run(w: &World, anchor: IdentityRule) -> Run { + let e = encode(w); + assert!(e.ad_refused.is_empty(), "refused: {:?}", e.ad_refused); + run_encoded(&e, anchor, &bindings()) +} + +fn run_encoded(e: &Encoded, anchor: IdentityRule, b: &[Binding]) -> Run { + let lanes = Lanes::build( + &profile(anchor), + b, + Source { + records: &e.ad, + pool: &e.ad_pool, + }, + Source { + records: &e.entra, + pool: &e.entra_pool, + }, + Source { + records: &e.exo, + pool: &e.exo_pool, + }, + ); + let ix = Index::build(&lanes); + let mut out = Output::for_index(&lanes, &ix); + fold(&lanes, &ix, &mut out); + assert_eq!(out.edges.len(), ix.edges, "the index counts every edge"); + // The same rows on one ruler: the position-wise fold confirms exactly + // the slots the join confirms. + let ruler = Ruler::from_fold(&lanes, &ix, &out); + let mut planes = Planes::for_ruler(&ruler); + fold_aligned(&ruler, &mut planes); + for (i, &f) in out.ad.iter().enumerate() { + let bit = planes.confirmed(i / 64) >> (i % 64) & 1 == 1; + assert_eq!( + bit, + AdStatus::of(f) == AdStatus::Confirmed, + "slot {i}: ruler vs join, ad {f:x} entra {:x?}", + out.entra + ); + } + Run { lanes, out } +} + +fn statuses(r: &Run) -> Vec { + r.out.ad.iter().map(|&f| AdStatus::of(f)).collect() +} + +fn edges(r: &Run, ev: EdgeEvidence) -> Vec<(Guid128, Guid128)> { + r.out + .edges + .iter() + .filter(|e| e.evidence == ev) + .map(|e| (e.src.1, e.dst.1)) + .collect() +} + +// ---- the independent reference -------------------------------------------- + +/// Expected status per AD row, from the specification only. +fn oracle(w: &World, anchor_is_object_guid: bool, b: &[Binding], skew: i64) -> Vec { + let tenant_of = |d: Guid128| b.iter().find(|x| x.ad_domain == d).map(|x| x.tenant); + let anchor = |a: &Ad| { + if anchor_is_object_guid { + Wire::Id(a.guid) + } else { + a.cg + } + }; + let mut res = Vec::new(); + for a in &w.ad { + let Some(t) = tenant_of(a.domain) else { + res.push(AdStatus::OutOfScope); + continue; + }; + let back_target: Vec<&Entra> = match a.backsync { + Wire::Id(id) => w + .entra + .iter() + .filter(|e| e.tenant == t && e.id == id) + .collect(), + _ => vec![], + }; + let back_present = matches!(a.backsync, Wire::Id(_)); + let id = match anchor(a) { + Wire::Malformed | Wire::Nil => { + res.push(AdStatus::InvalidSourceAnchor); + continue; + } + Wire::Absent => { + let split = back_target.len() == 1 + && split_at(w, b, t, back_target[0], anchor_is_object_guid); + res.push(if split { + AdStatus::Contradictory + } else if back_target.len() == 1 { + AdStatus::BacksyncOnly + } else { + AdStatus::MissingSourceAnchor + }); + continue; + } + Wire::Id(id) => id, + }; + let sharers = + w.ad.iter() + .filter(|x| tenant_of(x.domain) == Some(t) && anchor(x) == Wire::Id(id)) + .count(); + if sharers > 1 { + res.push(AdStatus::AmbiguousSource); + continue; + } + let fwd: Vec<&Entra> = w + .entra + .iter() + .filter(|e| e.tenant == t && e.immutable == Wire::Id(id)) + .collect(); + if fwd.len() > 1 { + res.push(AdStatus::AmbiguousTarget); + continue; + } + if fwd.is_empty() { + res.push(if back_target.len() == 1 { + AdStatus::Contradictory + } else { + AdStatus::Unresolved + }); + continue; + } + let x = fwd[0]; + let split = split_at(w, b, t, x, anchor_is_object_guid) + || (back_target.len() == 1 && split_at(w, b, t, back_target[0], anchor_is_object_guid)); + let agree = back_target.len() == 1 && back_target[0].id == x.id; + res.push(if split || (back_present && !agree) { + AdStatus::Contradictory + } else if agree { + if a.at != 0 && x.at != 0 && (a.at - x.at).abs() <= skew { + AdStatus::Confirmed + } else { + AdStatus::AgreesAcrossTime + } + } else { + AdStatus::ForwardOnly + }); + } + res +} + +/// Entra row `x` is contested: claimed or named by several AD rows, or +/// claimed by one and named by another. +fn split_at(w: &World, b: &[Binding], t: Guid128, x: &Entra, by_object_guid: bool) -> bool { + let in_t = |a: &&Ad| b.iter().any(|y| y.ad_domain == a.domain && y.tenant == t); + let claimers: Vec<&Ad> = match x.immutable { + Wire::Id(id) => { + w.ad.iter() + .filter(in_t) + .filter(|a| { + (if by_object_guid { + Wire::Id(a.guid) + } else { + a.cg + }) == Wire::Id(id) + }) + .collect() + } + _ => vec![], + }; + let namers: Vec<&Ad> = + w.ad.iter() + .filter(in_t) + .filter(|a| a.backsync == Wire::Id(x.id)) + .collect(); + // Rows are compared as rows: two observations can carry one objectGUID. + claimers.len() > 1 + || namers.len() > 1 + || (claimers.len() == 1 && namers.len() == 1 && !std::ptr::eq(claimers[0], namers[0])) +} + +fn check(w: &World, anchor: IdentityRule) -> Run { + let r = run(w, anchor); + let want = oracle( + w, + anchor == OBJECT_GUID_TO_IMMUTABLE_ID, + &bindings(), + 60_000, + ); + assert_eq!( + statuses(&r), + want, + "fold disagrees with the reference: {w:#?}\nflags {:x?} entra {:x?}", + r.out.ad, + r.out.entra + ); + r +} + +// ---- F1-F14 ---------------------------------------------------------------- + +/// F1: the anchor is `mS-DS-ConsistencyGuid` and differs from `objectGUID`. +#[test] +fn f01_consistency_guid_differs_from_object_guid() { + let (a, s, x) = (g(1), g(2), g(3)); + let w = World { + ad: vec![ad(a, Wire::Id(s), Wire::Id(x))], + entra: vec![entra(x, Wire::Id(s))], + ..World::default() + }; + assert_ne!(a, s); + let r = check(&w, CONSISTENCY_GUID_TO_IMMUTABLE_ID); + assert_eq!(statuses(&r), [AdStatus::Confirmed]); + assert_eq!( + edges(&r, EdgeEvidence::SourceAnchorMatchesImmutableId), + [(a, x)] + ); + assert!(edges(&r, EdgeEvidence::ImmutableIdIsObjectGuid).is_empty()); + // Two nodes, related; neither carries the other's id. + assert_eq!(r.lanes.ad.owner[0], a); + assert_eq!(r.lanes.entra.owner[0], x); + + // The objectGUID profile must not claim this correspondence. + let r = check(&w, OBJECT_GUID_TO_IMMUTABLE_ID); + assert_eq!(statuses(&r), [AdStatus::Contradictory]); + assert!(edges(&r, EdgeEvidence::ImmutableIdIsObjectGuid).is_empty()); + // The existing objectGUID-only API finds nothing either. + let e = encode(&w); + let observed = std::collections::HashSet::from([a]); + assert!(ogar_az::sync_edges(&e.entra, &e.entra_pool, &observed).is_empty()); +} + +/// F2: a deployment that anchors on `objectGUID`. +#[test] +fn f02_explicit_object_guid_profile() { + let (a, x) = (g(1), g(3)); + let w = World { + ad: vec![ad(a, Wire::Absent, Wire::Id(x))], + entra: vec![entra(x, Wire::Id(a))], + ..World::default() + }; + let r = check(&w, OBJECT_GUID_TO_IMMUTABLE_ID); + assert_eq!(statuses(&r), [AdStatus::Confirmed]); + // Legacy evidence code, legacy meaning. + assert_eq!(edges(&r, EdgeEvidence::ImmutableIdIsObjectGuid), [(a, x)]); +} + +/// F3: no implicit fallback. The configured anchor is missing; `objectGUID` +/// happens to match the immutable id. +#[test] +fn f03_no_fallback_to_object_guid() { + let (a, x) = (g(1), g(3)); + let w = World { + ad: vec![ad(a, Wire::Absent, Wire::Absent)], + entra: vec![entra(x, Wire::Id(a))], + ..World::default() + }; + let r = check(&w, CONSISTENCY_GUID_TO_IMMUTABLE_ID); + assert_eq!(statuses(&r), [AdStatus::MissingSourceAnchor]); + assert!(r.out.edges.is_empty()); + assert_eq!(r.out.entra[0] & flag::E_ORPHAN, flag::E_ORPHAN); +} + +/// F4: backsync agrees with the anchor. +#[test] +fn f04_backsync_agrees() { + let (a, s, x) = (g(1), g(2), g(3)); + let w = World { + ad: vec![ad(a, Wire::Id(s), Wire::Id(x))], + entra: vec![entra(x, Wire::Id(s))], + ..World::default() + }; + let r = check(&w, CONSISTENCY_GUID_TO_IMMUTABLE_ID); + assert_ne!(r.out.ad[0] & flag::AGREE, 0); + assert_eq!( + edges(&r, EdgeEvidence::AdBacksyncMatchesEntraObjectId), + [(a, x)] + ); +} + +/// F5: the anchor reaches Entra X, the backsync names Entra Y. +#[test] +fn f05_backsync_contradicts() { + let (a, s, x, y) = (g(1), g(2), g(3), g(4)); + let w = World { + ad: vec![ad(a, Wire::Id(s), Wire::Id(y))], + entra: vec![entra(x, Wire::Id(s)), entra(y, Wire::Absent)], + ..World::default() + }; + let r = check(&w, CONSISTENCY_GUID_TO_IMMUTABLE_ID); + assert_eq!(statuses(&r), [AdStatus::Contradictory]); + // Both witnesses stay: no repair, no choice between them. + assert_eq!( + edges(&r, EdgeEvidence::SourceAnchorMatchesImmutableId), + [(a, x)] + ); + assert_eq!( + edges(&r, EdgeEvidence::AdBacksyncMatchesEntraObjectId), + [(a, y)] + ); +} + +/// F5b: one cloud object, claimed by one AD row's anchor and named by +/// another AD row's backsync. +#[test] +fn f05b_witnesses_split_across_ad_rows() { + let (a1, a2, s, x) = (g(1), g(5), g(2), g(3)); + let w = World { + ad: vec![ + ad(a1, Wire::Id(s), Wire::Absent), + ad(a2, Wire::Absent, Wire::Id(x)), + ], + entra: vec![entra(x, Wire::Id(s))], + ..World::default() + }; + let r = check(&w, CONSISTENCY_GUID_TO_IMMUTABLE_ID); + assert_eq!( + statuses(&r), + [AdStatus::Contradictory, AdStatus::Contradictory] + ); + assert_ne!(r.out.entra[0] & flag::E_SPLIT, 0); +} + +/// F6: two AD objects carry one anchor. +#[test] +fn f06_duplicate_source_anchor() { + let (a1, a2, s, x) = (g(1), g(5), g(2), g(3)); + let w = World { + ad: vec![ + ad(a1, Wire::Id(s), Wire::Absent), + ad(a2, Wire::Id(s), Wire::Absent), + ], + entra: vec![entra(x, Wire::Id(s))], + ..World::default() + }; + let r = check(&w, CONSISTENCY_GUID_TO_IMMUTABLE_ID); + assert_eq!( + statuses(&r), + [AdStatus::AmbiguousSource, AdStatus::AmbiguousSource] + ); + // Both witnesses kept; the order of the input does not pick a winner. + let mut got = edges(&r, EdgeEvidence::SourceAnchorMatchesImmutableId); + got.sort(); + let mut want = vec![(a1, x), (a2, x)]; + want.sort(); + assert_eq!(got, want); + assert_ne!(r.out.entra[0] & flag::E_CLAIMED_MANY, 0); +} + +/// F7: two cloud objects carry one anchor. +#[test] +fn f07_cloud_duplication() { + let (a, s, x, y) = (g(1), g(2), g(3), g(4)); + let w = World { + ad: vec![ad(a, Wire::Id(s), Wire::Absent)], + entra: vec![entra(x, Wire::Id(s)), entra(y, Wire::Id(s))], + ..World::default() + }; + let r = check(&w, CONSISTENCY_GUID_TO_IMMUTABLE_ID); + assert_eq!(statuses(&r), [AdStatus::AmbiguousTarget]); + assert_eq!(r.out.ad_target[0], ogar_dir_core::correspond::NO_ROW); + assert!(r.out.entra.iter().all(|f| f & flag::E_ANCHOR_SHARED != 0)); +} + +/// F8: the label must match the object's kind. +#[test] +fn f08_wrong_label_is_refused() { + let x = g(3); + let mut grp = ad(g(1), Wire::Absent, Wire::Absent); + grp.group = true; + let mut entry = ad_entry(&grp); + entry.attrs.push(( + "msDS-ExternalDirectoryObjectId".into(), + format!("User_{x}").into_bytes(), + )); + let (mut d, mut p) = (OuDictionary::new(), ValuePool::new()); + assert_eq!( + ogar_ad::encode(&entry, g(DOMAIN), &mut d, &mut p, 1).unwrap_err(), + ogar_ad::AdError::BadId("msDS-ExternalDirectoryObjectId") + ); + let mut user = ad_entry(&ad(g(1), Wire::Absent, Wire::Absent)); + user.attrs.push(( + "msDS-ExternalDirectoryObjectId".into(), + format!("Group_{x}").into_bytes(), + )); + assert!(ogar_ad::encode(&user, g(DOMAIN), &mut d, &mut p, 1).is_err()); + // And the codec agrees on its own. + let rule = BACKSYNC_TO_ENTRA_ID.source.encoding; + assert!(rule.decode(format!("Group_{x}").as_bytes()).is_err()); + assert_eq!(rule.decode(format!("User_{x}").as_bytes()), Ok(x)); +} + +/// F9: Microsoft's mixed-endian vector and its base64. +#[test] +fn f09_byte_order() { + let id = Guid128::parse("3f2504e0-4f89-11d3-9a0c-0305e82c3301").unwrap(); + let b64 = "4AQlP4lP0xGaDAMF6CwzAQ=="; + let rule = CONSISTENCY_GUID_TO_IMMUTABLE_ID; + assert_eq!(rule.target.encoding.decode(b64.as_bytes()), Ok(id)); + // The naive reading, bytes in wire order, is a different id. + let wire = base64::decode(b64).unwrap(); + assert_ne!(Guid128(wire.clone().try_into().unwrap()), id); + // End to end: the AD side stores the decoded anchor, the Entra side the + // base64; they meet only through the declared conversion. + let w = World { + ad: vec![ad(g(1), Wire::Id(id), Wire::Absent)], + entra: vec![Entra { + immutable: Wire::Id(id), + ..entra(g(3), Wire::Absent) + }], + ..World::default() + }; + let e = encode(&w); + assert_eq!( + ogar_az::attr_str(&e.entra[0], &e.entra_pool, "onPremisesImmutableId"), + Some(b64) + ); + assert_eq!(statuses(&check(&w, rule)), [AdStatus::ForwardOnly]); +} + +/// F10: observations taken at different times. +#[test] +fn f10_stale_observation_is_not_simultaneous() { + let (a, s, x) = (g(1), g(2), g(3)); + let mut w = World { + ad: vec![ad(a, Wire::Id(s), Wire::Id(x))], + entra: vec![entra(x, Wire::Id(s))], + ..World::default() + }; + w.entra[0].at = 1_000 + 3_600_000; + assert_eq!( + statuses(&check(&w, CONSISTENCY_GUID_TO_IMMUTABLE_ID)), + [AdStatus::AgreesAcrossTime] + ); + w.entra[0].at = 0; // unknown time + assert_eq!( + statuses(&check(&w, CONSISTENCY_GUID_TO_IMMUTABLE_ID)), + [AdStatus::AgreesAcrossTime] + ); +} + +/// F11: identical bytes in another tenant do not join. +#[test] +fn f11_same_guid_other_scope() { + let (a, s, x, y) = (g(1), g(2), g(3), g(4)); + let mut stranger = entra(y, Wire::Id(s)); + stranger.tenant = g(OTHER_TENANT); + let mut unbound = ad(g(6), Wire::Id(s), Wire::Absent); + unbound.domain = g(OTHER_DOMAIN); + let w = World { + ad: vec![ad(a, Wire::Id(s), Wire::Id(x)), unbound], + entra: vec![entra(x, Wire::Id(s)), stranger], + ..World::default() + }; + let r = check(&w, CONSISTENCY_GUID_TO_IMMUTABLE_ID); + assert_eq!(statuses(&r), [AdStatus::Confirmed, AdStatus::OutOfScope]); + assert!( + r.out.edges.iter().all(|e| e.dst.1 != y), + "no edge into the other tenant" + ); + assert_ne!(r.out.entra[1] & flag::E_UNBOUND_SCOPE, 0); +} + +/// F12: absent, malformed, nil and unmatched are four different answers. +#[test] +fn f12_missing_malformed_nil_unknown() { + let s = g(2); + // AD refuses a malformed or nil consistency guid at ingest: the entry is + // not observed at all, it does not become a row with no anchor. + for bad in [Wire::Malformed, Wire::Nil] { + let w = World { + ad: vec![ad(g(1), bad, Wire::Absent)], + ..World::default() + }; + assert_eq!(encode(&w).ad_refused, [0], "{bad:?}"); + } + // The cloud side keeps its raw value, so every state reaches the lane. + let w = World { + ad: vec![ + ad(g(1), Wire::Absent, Wire::Absent), + ad(g(5), Wire::Id(s), Wire::Absent), + ], + entra: vec![ + entra(g(3), Wire::Absent), + entra(g(4), Wire::Malformed), + entra(g(7), Wire::Nil), + entra(g(8), Wire::Id(g(99))), + ], + ..World::default() + }; + let r = check(&w, CONSISTENCY_GUID_TO_IMMUTABLE_ID); + assert_eq!( + statuses(&r), + [AdStatus::MissingSourceAnchor, AdStatus::Unresolved] + ); + let e = &r.out.entra; + assert_ne!(e[0] & flag::E_ANCHOR_ABSENT, 0); + assert_ne!(e[1] & flag::E_ANCHOR_FAULT, 0); + assert_ne!(e[2] & flag::E_ANCHOR_FAULT, 0); + assert_ne!(e[3] & flag::E_ORPHAN, 0); + assert_eq!(e[1] & flag::E_ANCHOR_ABSENT, 0, "malformed is not absent"); + use ogar_dir_core::correspond::state; + assert_eq!( + r.lanes.entra_anchor.state, + [state::ABSENT, state::MALFORMED, state::NIL, state::PRESENT] + ); +} + +/// F13: matching UPN and mail without an id witness relates nothing. +#[test] +fn f13_soft_match_is_not_a_hard_match() { + let w = World { + ad: vec![ad(g(1), Wire::Absent, Wire::Absent)], + entra: vec![Entra { + upn: Some("anna@example.org"), + ..entra(g(3), Wire::Absent) + }], + ..World::default() + }; + let r = check(&w, CONSISTENCY_GUID_TO_IMMUTABLE_ID); + assert_eq!(statuses(&r), [AdStatus::MissingSourceAnchor]); + assert!(r.out.edges.is_empty()); +} + +/// F14: the fold leaves every observation byte-identical. +#[test] +fn f14_observations_are_not_touched() { + let (a, s, x) = (g(1), g(2), g(3)); + let w = World { + ad: vec![ad(a, Wire::Id(s), Wire::Id(x))], + entra: vec![entra(x, Wire::Id(s))], + exo: vec![Exo { + guid: g(40), + tenant: g(TENANT), + external: Wire::Id(x), + }], + }; + let e = encode(&w); + let before: Vec<[u8; 512]> = + e.ad.iter() + .chain(&e.entra) + .chain(&e.exo) + .map(|r| *r.as_bytes()) + .collect(); + let (ap, ep) = ( + e.ad_pool.as_bytes().to_vec(), + e.entra_pool.as_bytes().to_vec(), + ); + let r = run_encoded(&e, CONSISTENCY_GUID_TO_IMMUTABLE_ID, &bindings()); + let after: Vec<[u8; 512]> = + e.ad.iter() + .chain(&e.entra) + .chain(&e.exo) + .map(|r| *r.as_bytes()) + .collect(); + assert_eq!(before, after); + assert_eq!(ap, e.ad_pool.as_bytes()); + assert_eq!(ep, e.entra_pool.as_bytes()); + // The cloud witness, Entra → Exchange Online. + assert_eq!( + edges(&r, EdgeEvidence::EntraObjectIdMatchesExchangeExternalId), + [(x, g(40))] + ); + assert_ne!(r.out.entra[0] & flag::E_EXO_ONE, 0); + assert_ne!(r.out.exo[0] & flag::X_ONE, 0); +} + +#[test] +fn exchange_online_dangling_and_duplicate() { + let x = g(3); + let exo = |n, ext| Exo { + guid: g(n), + tenant: g(TENANT), + external: ext, + }; + let w = World { + entra: vec![entra(x, Wire::Absent)], + exo: vec![ + exo(40, Wire::Id(x)), + exo(41, Wire::Id(x)), + exo(42, Wire::Id(g(77))), + exo(43, Wire::Absent), + ], + ..World::default() + }; + let r = run(&w, CONSISTENCY_GUID_TO_IMMUTABLE_ID); + assert_ne!(r.out.entra[0] & flag::E_EXO_MANY, 0); + assert_eq!( + r.out.exo, + [flag::X_ONE, flag::X_ONE, flag::X_DANGLING, flag::X_ABSENT] + ); +} + +// ---- randomized comparison -------------------------------------------------- + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } +} + +/// Small id spaces so collisions, duplicates, splits and contradictions all +/// occur; two domains, one bound. +fn random_world(rng: &mut Rng) -> World { + let pick = |rng: &mut Rng, space: u64| match rng.below(10) { + 0..=1 => Wire::Absent, + _ => Wire::Id(g(100 + rng.below(space))), + }; + let entra_ids: Vec = (0..rng.below(8)).map(|i| g(500 + i)).collect(); + let n_ad = rng.below(8); + let ad_rows = (0..n_ad) + .map(|i| { + let back = match rng.below(4) { + 0 => Wire::Absent, + 1 => Wire::Id(g(500 + rng.below(10))), + _ if !entra_ids.is_empty() => { + Wire::Id(entra_ids[rng.below(entra_ids.len() as u64) as usize]) + } + _ => Wire::Absent, + }; + Ad { + guid: g(if rng.below(5) == 0 { + 100 + rng.below(6) + } else { + 1_000 + i + }), + domain: g(if rng.below(6) == 0 { + OTHER_DOMAIN + } else { + DOMAIN + }), + cg: pick(rng, 6), + backsync: back, + group: false, + at: [0, 1_000, 2_000, 900_000][rng.below(4) as usize], + } + }) + .collect(); + let entra_rows = entra_ids + .iter() + .map(|&id| Entra { + id, + tenant: g(if rng.below(6) == 0 { + OTHER_TENANT + } else { + TENANT + }), + immutable: match rng.below(12) { + 0 => Wire::Malformed, + _ => pick(rng, 6), + }, + upn: None, + at: [0, 1_000, 30_000][rng.below(3) as usize], + }) + .collect(); + World { + ad: ad_rows, + entra: entra_rows, + exo: vec![], + } +} + +#[test] +fn randomized_fold_matches_the_reference() { + let mut rng = Rng(0x5EED_1D3A_7172_0001); + let mut seen = std::collections::HashSet::new(); + for _ in 0..3_000 { + let w = random_world(&mut rng); + for rule in [ + CONSISTENCY_GUID_TO_IMMUTABLE_ID, + OBJECT_GUID_TO_IMMUTABLE_ID, + ] { + let r = check(&w, rule); + seen.extend(statuses(&r)); + } + } + // Anti-vacuity: the generator reached every outcome the fold can name. + for s in [ + AdStatus::OutOfScope, + AdStatus::AmbiguousSource, + AdStatus::AmbiguousTarget, + AdStatus::Contradictory, + AdStatus::Confirmed, + AdStatus::AgreesAcrossTime, + AdStatus::ForwardOnly, + AdStatus::BacksyncOnly, + AdStatus::MissingSourceAnchor, + AdStatus::Unresolved, + ] { + assert!(seen.contains(&s), "never generated {s:?}"); + } +} diff --git a/crates/ogar-dir-core/examples/identity_fold_bench.rs b/crates/ogar-dir-core/examples/identity_fold_bench.rs new file mode 100644 index 00000000..a4468e60 --- /dev/null +++ b/crates/ogar-dir-core/examples/identity_fold_bench.rs @@ -0,0 +1,382 @@ +//! Measures the identity correspondence fold, stage by stage. +//! +//! ```text +//! cargo run --release -p ogar-dir-core --example identity_fold_bench +//! ``` +//! +//! Every stage is timed separately (boundary decode, index build, fold), with +//! warm-up and repetitions; the allocator counts allocations and peak bytes, +//! so "zero allocations in the fold" is a measurement, not a claim. + +use ogar_dir_core::correspond::{ + AdStatus, IdColumn, Index, Lanes, NO_ROW, Output, Planes, Rows, Ruler, UNBOUND, fold, + fold_aligned, state, +}; +use ogar_dir_core::{Encoding, Guid128, LabelPattern}; +use std::alloc::{GlobalAlloc, Layout, System}; +use std::collections::HashMap; +use std::hint::black_box; +use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; +use std::time::Instant; + +struct Counting; +static ALLOCS: AtomicUsize = AtomicUsize::new(0); +static LIVE: AtomicUsize = AtomicUsize::new(0); +static PEAK: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + ALLOCS.fetch_add(1, Relaxed); + let now = LIVE.fetch_add(l.size(), Relaxed) + l.size(); + PEAK.fetch_max(now, Relaxed); + unsafe { System.alloc(l) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + LIVE.fetch_sub(l.size(), Relaxed); + unsafe { System.dealloc(p, l) } + } +} +#[global_allocator] +static A: Counting = Counting; + +const TENANTS: u32 = 16; +const PER_TENANT: u32 = 65_536; // the directory simulator's population cap +const N: usize = (TENANTS * PER_TENANT) as usize; // 1,048,576 +const WARM: usize = 3; +const REPS: usize = 15; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } +} + +fn id(rng: &mut Rng) -> Guid128 { + let mut b = [0u8; 16]; + b[..8].copy_from_slice(&rng.next().to_le_bytes()); + b[8..].copy_from_slice(&rng.next().to_le_bytes()); + Guid128(b) +} + +/// Median and maximum of `REPS` timed runs (after `WARM` untimed ones), in +/// nanoseconds, plus allocations made inside the timed runs. +fn time(mut f: impl FnMut()) -> (u128, u128, usize) { + for _ in 0..WARM { + f(); + } + let mut t: Vec = Vec::with_capacity(REPS); + let a0 = ALLOCS.load(Relaxed); + for _ in 0..REPS { + let s = Instant::now(); + f(); + t.push(s.elapsed().as_nanos()); + } + let allocs = ALLOCS.load(Relaxed) - a0; + t.sort_unstable(); + (t[REPS / 2], t[REPS - 1], allocs) +} + +fn row(name: &str, ops: usize, (med, max, allocs): (u128, u128, usize)) { + println!( + "| {name:<44} | {ops:>9} | {:>9.3} | {:>9.3} | {:>7.2} | {:>8.1} | {allocs:>6} |", + med as f64 / 1e6, + max as f64 / 1e6, + med as f64 / ops as f64, + ops as f64 / (med as f64 / 1e9) / 1e6, + ); +} + +/// The workload knobs. +#[derive(Clone, Copy)] +struct Shape { + /// Percent of AD rows with an anchor that some Entra row carries. + match_pct: u64, + /// Percent of AD rows whose anchor is absent. + absent_pct: u64, + /// Percent of AD rows that share their anchor with the next row. + dup_pct: u64, + /// Percent of AD rows in an unbound scope. + unbound_pct: u64, +} + +fn lanes(shape: Shape, seed: u64) -> Lanes { + let mut rng = Rng(seed); + let mut l = Lanes { + max_skew_ms: 60_000, + ..Lanes::default() + }; + let rows = |n: usize| Rows { + owner: Vec::with_capacity(n), + scope: Vec::with_capacity(n), + at: Vec::with_capacity(n), + }; + let col = |n: usize| IdColumn { + id: Vec::with_capacity(n), + state: Vec::with_capacity(n), + }; + (l.ad, l.entra, l.exo) = (rows(N), rows(N), rows(N)); + (l.ad_anchor, l.ad_backsync, l.entra_anchor, l.exo_external) = (col(N), col(N), col(N), col(N)); + let mut prev_anchor = Guid128::NIL; + for i in 0..N { + let tenant = i as u32 / PER_TENANT; + let (entra_id, anchor) = (id(&mut rng), id(&mut rng)); + let roll = rng.next() % 100; + // Entra: every row a cloud object; the immutable id is the anchor + // for `match_pct` of them, an unrelated id otherwise. + l.entra.owner.push(entra_id); + l.entra.scope.push(tenant); + l.entra.at.push(1_000); + let carried = roll < shape.match_pct; + l.entra_anchor + .id + .push(if carried { anchor } else { id(&mut rng) }); + l.entra_anchor.state.push(state::PRESENT); + // AD. + l.ad.owner.push(id(&mut rng)); + l.ad.scope.push(if rng.next() % 100 < shape.unbound_pct { + UNBOUND + } else { + tenant + }); + l.ad.at.push(1_000); + let a_roll = rng.next() % 100; + if a_roll < shape.absent_pct { + l.ad_anchor.id.push(Guid128::NIL); + l.ad_anchor.state.push(state::ABSENT); + } else { + let a = if a_roll < shape.absent_pct + shape.dup_pct && !prev_anchor.is_nil() { + prev_anchor + } else { + anchor + }; + prev_anchor = a; + l.ad_anchor.id.push(a); + l.ad_anchor.state.push(state::PRESENT); + } + l.ad_backsync.id.push(entra_id); + l.ad_backsync.state.push(state::PRESENT); + // Exchange Online: one mailbox per cloud object. + l.exo.owner.push(id(&mut rng)); + l.exo.scope.push(tenant); + l.exo.at.push(1_000); + l.exo_external.id.push(entra_id); + l.exo_external.state.push(state::PRESENT); + } + l +} + +fn fold_case(name: &str, shape: Shape) { + let l = lanes(shape, 0xC0FF_EE00_0000_0001); + let a0 = ALLOCS.load(Relaxed); + let t = Instant::now(); + let ix = Index::build(&l); + let build_ns = t.elapsed().as_nanos(); + let build_allocs = ALLOCS.load(Relaxed) - a0; + let mut out = Output::for_index(&l, &ix); + let r = time(|| { + fold(black_box(&l), black_box(&ix), &mut out); + black_box(&out); + }); + row(&format!("C fold, {name}"), N, r); + println!( + "| index build (cold, once) | {N:>9} | {:>9.3} | | | | {build_allocs:>6} |", + build_ns as f64 / 1e6 + ); + let mut hist: HashMap = HashMap::new(); + for &f in &out.ad { + *hist.entry(AdStatus::of(f)).or_default() += 1; + } + let mut h: Vec<_> = hist.into_iter().collect(); + h.sort_by_key(|(_, n)| std::cmp::Reverse(*n)); + println!("| edges {} · outcomes {h:?}", out.edges.len()); + let targeted = out.ad_target.iter().filter(|&&t| t != NO_ROW).count(); + println!("| AD rows with exactly one forward target: {targeted}"); + + // The same population on one ruler: slot i is the same user in every + // column, so each witness is a position-wise compare. + let t = Instant::now(); + let ruler = Ruler::from_fold(&l, &ix, &out); + let lay_ns = t.elapsed().as_nanos(); + let mut planes = Planes::for_ruler(&ruler); + let r = time(|| { + fold_aligned(black_box(&ruler), &mut planes); + black_box(&planes); + }); + row(&format!("R aligned fold, {name}"), N, r); + println!( + "| lay onto ruler (cold, once) | {N:>9} | {:>9.3} | | | | |", + lay_ns as f64 / 1e6 + ); + let confirmed: u32 = (0..planes.forward.len()) + .map(|w| planes.confirmed(w).count_ones()) + .sum(); + let joined = out + .ad + .iter() + .filter(|&&f| AdStatus::of(f) == AdStatus::Confirmed) + .count(); + println!("| confirmed slots {confirmed} (join fold: {joined})"); +} + +fn main() { + println!( + "rustc: {}", + option_env!("RUSTC_VERSION").unwrap_or("see `rustc -V`") + ); + if let Ok(c) = std::fs::read_to_string("/proc/cpuinfo") { + if let Some(m) = c.lines().find(|l| l.starts_with("model name")) { + println!("cpu: {}", m.split(':').nth(1).unwrap_or("").trim()); + } + println!( + "logical cpus: {} (single-threaded run)", + c.lines().filter(|l| l.starts_with("processor")).count() + ); + } + println!( + "profile: {}", + if cfg!(debug_assertions) { + "debug" + } else { + "release" + } + ); + println!("warm-up {WARM}, repetitions {REPS}, rows per side {N}\n"); + println!( + "| stage | ops | median ms | max ms | ns / op | Mops / s | allocs |" + ); + println!( + "|----------------------------------------------|-----------|-----------|-----------|---------|----------|--------|" + ); + + // A: pure equality, 50 % equal. + let mut rng = Rng(7); + let xs: Vec = (0..N).map(|_| id(&mut rng)).collect(); + let ys: Vec = xs + .iter() + .map(|x| { + if rng.next().is_multiple_of(2) { + *x + } else { + id(&mut rng) + } + }) + .collect(); + let r = time(|| { + let eq = xs.iter().zip(&ys).filter(|(a, b)| a == b).count(); + black_box(eq); + }); + row("A Guid128 equality (50 % equal)", N, r); + + // B: indexed lookups, two baselines, prebuilt (build time excluded). + let keys: Vec = (0..N).map(|_| id(&mut rng)).collect(); + let probes: Vec = keys + .iter() + .map(|k| { + if rng.next().is_multiple_of(2) { + *k + } else { + id(&mut rng) + } + }) + .collect(); + let map: HashMap = keys + .iter() + .enumerate() + .map(|(i, k)| (*k, i as u32)) + .collect(); + let r = time(|| { + let hit = probes.iter().filter(|p| map.contains_key(p)).count(); + black_box(hit); + }); + row("B HashMap lookup (50 % hit)", N, r); + let mut sorted = keys.clone(); + sorted.sort_unstable(); + let r = time(|| { + let hit = probes + .iter() + .filter(|p| sorted.binary_search(p).is_ok()) + .count(); + black_box(hit); + }); + row("B sorted Guid128 binary search (50 % hit)", N, r); + + // C: the full three-witness fold. + let typical = Shape { + match_pct: 95, + absent_pct: 3, + dup_pct: 0, + unbound_pct: 0, + }; + fold_case("typical (95 % matched)", typical); + + // E: adversarial shapes. + fold_case( + "sparse (1 % matched)", + Shape { + match_pct: 1, + ..typical + }, + ); + fold_case( + "duplicate anchors (20 %)", + Shape { + dup_pct: 20, + ..typical + }, + ); + fold_case( + "half absent anchors", + Shape { + absent_pct: 50, + ..typical + }, + ); + fold_case( + "half unbound scope", + Shape { + unbound_pct: 50, + ..typical + }, + ); + + // D: boundary conversions, which the fold never performs. + let ms: Vec<[u8; 16]> = keys.iter().map(|k| k.to_ms_bytes()).collect(); + let b64: Vec> = keys + .iter() + .map(|k| Encoding::Base64MsGuidBytes16.encode(*k)) + .collect(); + let r = time(|| { + for b in &b64 { + black_box(Encoding::Base64MsGuidBytes16.decode(b).ok()); + } + }); + row("D decode onPremisesImmutableId (base64)", N, r); + let r = time(|| { + for b in &ms { + black_box(Encoding::MsGuidBytes16.decode(b).ok()); + } + }); + row("D decode mixed-endian 16 bytes", N, r); + let user = Encoding::Labeled(LabelPattern("User_{0}")); + let r = time(|| { + for k in &keys { + black_box(user.encode(*k)); + } + }); + row("D render User_{0} (egress)", N, r); + let r = time(|| { + for k in &keys { + black_box(Encoding::Base64MsGuidBytes16.encode(*k)); + } + }); + row("D render base64 (egress)", N, r); + + println!( + "\npeak heap: {:.1} MiB", + PEAK.load(Relaxed) as f64 / (1 << 20) as f64 + ); +} diff --git a/crates/ogar-dir-core/src/correspond.rs b/crates/ogar-dir-core/src/correspond.rs new file mode 100644 index 00000000..590c88e8 --- /dev/null +++ b/crates/ogar-dir-core/src/correspond.rs @@ -0,0 +1,857 @@ +//! Hybrid identity correspondence as a numeric fold. +//! +//! AD, Entra and Exchange Online are observed separately and stay separate: +//! the fold relates rows, it never merges them, and it never edits a lane. +//! Three witnesses are read, each from its own [`CompiledRule`]: +//! +//! * **forward** — the AD source anchor equals the Entra `onPremisesImmutableId` +//! (the anchor is `mS-DS-ConsistencyGuid` or `objectGUID`, whichever rule +//! the [`Profile`] carries; there is no fallback between them); +//! * **backsync** — AD `msDS-ExternalDirectoryObjectId` equals the Entra id; +//! * **cloud** — the Exchange Online `ExternalDirectoryObjectId` equals the +//! Entra id. +//! +//! Each witness that holds becomes one [`DirEdge`] with its own evidence +//! code. Whether the witnesses agree, conflict, or are ambiguous is reported +//! per row as flag bits, and [`AdStatus`] names the outcome. Nothing picks a +//! "first" match: a key matched by several rows is ambiguous on both sides. +//! +//! ## Stages +//! +//! ```text +//! DirRecord + ValuePool ──Lanes::build──► Lanes (boundary: decodes once) +//! Lanes ──Index::build──► Index (cold: sorts, counts edges) +//! Lanes + Index ──fold──► Output (hot: numeric only) +//! ``` +//! +//! The fold reads `u32` scope ordinals and 128-bit ids, binary-searches the +//! sorted indexes and writes into an [`Output`] sized by `Index::build`. It +//! performs no string work, no decoding and no allocation once the output is +//! sized. +//! +//! ## Scope +//! +//! An AD domain and an Entra tenant are different id spaces, so a key is +//! only compared within a declared [`Binding`] (AD domain → tenant). A row +//! whose scope is not bound joins nothing, whatever its bytes. + +use crate::edge::{DirEdge, EdgeEvidence, EdgeKind}; +use crate::guid::Guid128; +use crate::identity::{CompiledRule, IdFault, IdValue, Slot}; +use crate::pool::ValuePool; +use crate::record::DirRecord; +use crate::schema::SchemaFamily; + +/// A row's scope ordinal when its scope is not bound. +pub const UNBOUND: u32 = u32::MAX; +/// "No row". +pub const NO_ROW: u32 = u32::MAX; + +/// One AD domain synchronized into one tenant. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Binding { + /// The AD domain GUID (an AD record's `scope_guid`). + pub ad_domain: Guid128, + /// The tenant id (an Entra / Exchange Online record's `scope_guid`). + pub tenant: Guid128, +} + +/// The rules a deployment uses, plus how far apart two observations may be +/// taken and still count as one moment. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Profile { + /// AD source anchor → Entra `onPremisesImmutableId`. + pub anchor: CompiledRule, + /// AD `msDS-ExternalDirectoryObjectId` → Entra id. + pub backsync: CompiledRule, + /// Entra id → Exchange Online `ExternalDirectoryObjectId`. + pub cloud: CompiledRule, + /// Maximum `|observed_at_ms(AD) - observed_at_ms(Entra)|`. An unknown + /// time (0) is never aligned. + pub max_skew_ms: i64, +} + +/// What a lane cell holds. +pub mod state { + #![allow(missing_docs)] + pub const ABSENT: u8 = 0; + pub const PRESENT: u8 = 1; + pub const MALFORMED: u8 = 2; + pub const NIL: u8 = 3; +} + +/// One decoded id column with its state column. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct IdColumn { + /// The id; [`Guid128::NIL`] unless the state is `PRESENT`. + pub id: Vec, + /// A [`state`] code. + pub state: Vec, +} + +impl IdColumn { + fn push(&mut self, v: IdValue) { + let (g, s) = match v { + IdValue::Absent => (Guid128::NIL, state::ABSENT), + IdValue::Fault(IdFault::Malformed) => (Guid128::NIL, state::MALFORMED), + IdValue::Fault(IdFault::Nil) => (Guid128::NIL, state::NIL), + IdValue::Id(g) => (g, state::PRESENT), + }; + self.id.push(g); + self.state.push(s); + } +} + +/// One family's rows as columns (structure of arrays). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Rows { + /// The row's own id (`node_guid`). + pub owner: Vec, + /// Scope ordinal (tenant), or [`UNBOUND`]. + pub scope: Vec, + /// `observed_at_ms`. + pub at: Vec, +} + +impl Rows { + /// Row count. + pub fn len(&self) -> usize { + self.owner.len() + } + /// No rows. + pub fn is_empty(&self) -> bool { + self.owner.is_empty() + } +} + +/// Every column the fold reads. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Lanes { + /// AD rows. + pub ad: Rows, + /// AD source anchor (per the profile's anchor rule). + pub ad_anchor: IdColumn, + /// AD backsync id. + pub ad_backsync: IdColumn, + /// Entra rows (owner = Entra id). + pub entra: Rows, + /// Entra `onPremisesImmutableId`. + pub entra_anchor: IdColumn, + /// Exchange Online rows. + pub exo: Rows, + /// Exchange Online `ExternalDirectoryObjectId`. + pub exo_external: IdColumn, + /// The anchor rule reads `objectGUID` (legacy evidence code). + pub anchor_is_object_guid: bool, + /// [`Profile::max_skew_ms`]. + pub max_skew_ms: i64, +} + +/// One family's records and the pool their strings live in. +#[derive(Clone, Copy, Debug)] +pub struct Source<'a> { + /// Records. + pub records: &'a [DirRecord], + /// Their pool. + pub pool: &'a ValuePool, +} + +impl Lanes { + /// Boundary: read and decode every id once. Records of another family + /// are skipped; every row keeps its own `node_guid`. + pub fn build( + p: &Profile, + bindings: &[Binding], + ad: Source<'_>, + entra: Source<'_>, + exo: Source<'_>, + ) -> Self { + let mut tenants: Vec = Vec::new(); + for b in bindings { + if !tenants.contains(&b.tenant) { + tenants.push(b.tenant); + } + } + let tenant_ord = |t: Guid128| { + tenants + .iter() + .position(|x| *x == t) + .map_or(UNBOUND, |i| i as u32) + }; + let domain_ord = |d: Guid128| { + bindings + .iter() + .find(|b| b.ad_domain == d) + .map_or(UNBOUND, |b| tenant_ord(b.tenant)) + }; + let mut l = Lanes { + anchor_is_object_guid: p.anchor.source.slot == Slot::Node, + max_skew_ms: p.max_skew_ms, + ..Self::default() + }; + let rows = |rows: &mut Rows, r: &DirRecord, scope: u32| { + rows.owner.push(r.node_guid()); + rows.scope.push(scope); + rows.at.push(r.observed_at_ms()); + }; + for r in ad + .records + .iter() + .filter(|r| r.schema().family == SchemaFamily::AdDs) + { + rows(&mut l.ad, r, domain_ord(r.scope_guid())); + l.ad_anchor.push(p.anchor.source.read(r, ad.pool)); + l.ad_backsync.push(p.backsync.source.read(r, ad.pool)); + } + for r in entra + .records + .iter() + .filter(|r| r.schema().family == SchemaFamily::MsGraph) + { + rows(&mut l.entra, r, tenant_ord(r.scope_guid())); + l.entra_anchor.push(p.anchor.target.read(r, entra.pool)); + } + for r in exo + .records + .iter() + .filter(|r| r.schema().family == SchemaFamily::ExchangeOnline) + { + rows(&mut l.exo, r, tenant_ord(r.scope_guid())); + l.exo_external.push(p.cloud.target.read(r, exo.pool)); + } + l + } +} + +/// One index entry: a key in a scope, and the row carrying it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct Entry { + scope: u32, + key: u128, + row: u32, +} + +/// The 16 bytes as one big-endian integer: the same bytes, the same order +/// and equality as [`Guid128`], compared in two machine words. +#[inline] +fn k(g: &Guid128) -> u128 { + u128::from_be_bytes(g.0) +} + +/// Rows of a column sorted by `(scope, key)`; unbound and non-present rows +/// are left out. +fn sorted(scope: &[u32], ids: &[Guid128], present: impl Fn(usize) -> bool) -> Vec { + let mut t: Vec = (0..ids.len()) + .filter(|&i| scope[i] != UNBOUND && present(i)) + .map(|i| Entry { + scope: scope[i], + key: k(&ids[i]), + row: i as u32, + }) + .collect(); + t.sort_unstable(); + t +} + +/// Walk two sorted indexes key group by key group, calling `on` with the +/// rows of each side that carry the key (one side may be empty). Linear in +/// the two lengths; reads both sequentially. +#[inline] +fn merge(l: &[Entry], r: &[Entry], mut on: impl FnMut(&[Entry], &[Entry])) { + let run = |s: &[Entry], i: usize| { + let (sc, key) = (s[i].scope, s[i].key); + i + s[i..] + .iter() + .take_while(|e| e.scope == sc && e.key == key) + .count() + }; + let (mut i, mut j) = (0, 0); + while i < l.len() || j < r.len() { + let ord = match (l.get(i), r.get(j)) { + (Some(a), Some(b)) => (a.scope, a.key).cmp(&(b.scope, b.key)), + (Some(_), None) => std::cmp::Ordering::Less, + _ => std::cmp::Ordering::Greater, + }; + match ord { + std::cmp::Ordering::Less => { + let ie = run(l, i); + on(&l[i..ie], &[]); + i = ie; + } + std::cmp::Ordering::Greater => { + let je = run(r, j); + on(&[], &r[j..je]); + j = je; + } + std::cmp::Ordering::Equal => { + let (ie, je) = (run(l, i), run(r, j)); + on(&l[i..ie], &r[j..je]); + (i, j) = (ie, je); + } + } + } +} + +/// The sorted indexes and the exact edge count the fold will produce. +#[derive(Clone, Debug, Default)] +pub struct Index { + entra_by_anchor: Vec, + entra_by_id: Vec, + ad_by_anchor: Vec, + ad_by_backsync: Vec, + exo_by_external: Vec, + /// Exact number of edges [`fold`] emits. + pub edges: usize, +} + +fn present(c: &IdColumn) -> impl Fn(usize) -> bool + '_ { + move |i| c.state[i] == state::PRESENT +} + +impl Index { + /// Cold: sort every key column and count the edges. + pub fn build(l: &Lanes) -> Self { + let mut ix = Self { + entra_by_anchor: sorted(&l.entra.scope, &l.entra_anchor.id, present(&l.entra_anchor)), + entra_by_id: sorted(&l.entra.scope, &l.entra.owner, |_| true), + ad_by_anchor: sorted(&l.ad.scope, &l.ad_anchor.id, present(&l.ad_anchor)), + ad_by_backsync: sorted(&l.ad.scope, &l.ad_backsync.id, present(&l.ad_backsync)), + exo_by_external: sorted(&l.exo.scope, &l.exo_external.id, present(&l.exo_external)), + edges: 0, + }; + let mut n = 0; + let mut count = |a: &[Entry], b: &[Entry]| n += a.len() * b.len(); + merge(&ix.ad_by_anchor, &ix.entra_by_anchor, &mut count); + merge(&ix.ad_by_backsync, &ix.entra_by_id, &mut count); + merge(&ix.exo_by_external, &ix.entra_by_id, &mut count); + ix.edges = n; + ix + } +} + +/// Per-row flag bits. A row's flags are the whole evidence; [`AdStatus`] +/// is a reading of them. +pub mod flag { + #![allow(missing_docs)] + // AD rows. + pub const ANCHOR_ABSENT: u32 = 1 << 0; + pub const ANCHOR_MALFORMED: u32 = 1 << 1; + pub const ANCHOR_NIL: u32 = 1 << 2; + pub const UNBOUND_SCOPE: u32 = 1 << 3; + /// No Entra row carries this anchor. + pub const FWD_NONE: u32 = 1 << 4; + pub const FWD_ONE: u32 = 1 << 5; + pub const FWD_MANY: u32 = 1 << 6; + /// Another AD row in the scope carries the same anchor. + pub const ANCHOR_SHARED: u32 = 1 << 7; + pub const BACK_ABSENT: u32 = 1 << 8; + pub const BACK_MALFORMED: u32 = 1 << 9; + /// The backsync id names no observed Entra row. + pub const BACK_DANGLING: u32 = 1 << 10; + pub const BACK_ONE: u32 = 1 << 11; + pub const BACK_MANY: u32 = 1 << 12; + /// Forward and backsync name the same Entra row. + pub const AGREE: u32 = 1 << 13; + /// Forward and backsync name different Entra rows, or one names a row + /// the other cannot reach. + pub const CONTRADICT: u32 = 1 << 14; + /// Both observation times known and within the profile's skew. + pub const TIME_ALIGNED: u32 = 1 << 15; + /// The Entra row this row reaches (by either witness) is contested + /// ([`E_SPLIT`]). + pub const TARGET_SPLIT: u32 = 1 << 16; + // Entra rows. + pub const E_ANCHOR_ABSENT: u32 = 1 << 0; + pub const E_ANCHOR_FAULT: u32 = 1 << 1; + /// The immutable id names no observed AD anchor. + pub const E_ORPHAN: u32 = 1 << 2; + pub const E_CLAIMED_ONE: u32 = 1 << 3; + pub const E_CLAIMED_MANY: u32 = 1 << 4; + pub const E_BACK_ONE: u32 = 1 << 5; + pub const E_BACK_MANY: u32 = 1 << 6; + /// Contested: several AD rows claim it by anchor or name it by backsync, + /// or the one claimer and the one namer are different AD rows. + pub const E_SPLIT: u32 = 1 << 7; + /// Another Entra row in the tenant carries the same immutable id. + pub const E_ANCHOR_SHARED: u32 = 1 << 8; + pub const E_EXO_ONE: u32 = 1 << 9; + pub const E_EXO_MANY: u32 = 1 << 10; + pub const E_UNBOUND_SCOPE: u32 = 1 << 11; + // Exchange Online rows. + pub const X_ABSENT: u32 = 1 << 0; + pub const X_FAULT: u32 = 1 << 1; + pub const X_DANGLING: u32 = 1 << 2; + pub const X_ONE: u32 = 1 << 3; + pub const X_MANY: u32 = 1 << 4; + pub const X_UNBOUND_SCOPE: u32 = 1 << 5; +} + +/// The fold's result. Sized by [`Output::for_index`]; [`fold`] only writes. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Output { + /// Per AD row. + pub ad: Vec, + /// Per AD row: the forward target (Entra row) when exactly one, else + /// [`NO_ROW`]. + pub ad_target: Vec, + /// Per AD row: the backsync target when exactly one, else [`NO_ROW`]. + pub ad_back: Vec, + /// Per Entra row. + pub entra: Vec, + /// Per Entra row: the AD row whose anchor alone claims it, else [`NO_ROW`]. + pub entra_claimer: Vec, + /// Per Entra row: the AD row whose backsync alone names it, else [`NO_ROW`]. + pub entra_namer: Vec, + /// Per Exchange Online row. + pub exo: Vec, + /// Witness edges. + pub edges: Vec, +} + +impl Output { + /// Allocate exactly what [`fold`] writes. + pub fn for_index(l: &Lanes, ix: &Index) -> Self { + Self { + ad: vec![0; l.ad.len()], + ad_target: vec![NO_ROW; l.ad.len()], + ad_back: vec![NO_ROW; l.ad.len()], + entra: vec![0; l.entra.len()], + entra_claimer: vec![NO_ROW; l.entra.len()], + entra_namer: vec![NO_ROW; l.entra.len()], + exo: vec![0; l.exo.len()], + edges: Vec::with_capacity(ix.edges), + } + } +} + +#[inline] +fn count_flag(n: usize, zero: u32, one: u32, many: u32) -> u32 { + match n { + 0 => zero, + 1 => one, + _ => many, + } +} + +#[inline] +fn sole(s: &[Entry]) -> u32 { + if s.len() == 1 { s[0].row } else { NO_ROW } +} + +/// Hot: relate the rows. `out` must come from [`Output::for_index`] on the +/// same lanes and index; it is overwritten. +/// +/// Three sort-merge joins over the prebuilt indexes (forward, backsync, +/// cloud), then one pass per family that reads the joins' results. Every +/// index is read sequentially; nothing is allocated. +pub fn fold(l: &Lanes, ix: &Index, out: &mut Output) { + out.edges.clear(); + out.ad_target.fill(NO_ROW); + out.ad_back.fill(NO_ROW); + out.entra_claimer.fill(NO_ROW); + out.entra_namer.fill(NO_ROW); + + // Row-local facts. + for i in 0..l.ad.len() { + let mut f = 0; + if l.ad.scope[i] == UNBOUND { + f |= flag::UNBOUND_SCOPE; + } + f |= match l.ad_anchor.state[i] { + state::ABSENT => flag::ANCHOR_ABSENT, + state::MALFORMED => flag::ANCHOR_MALFORMED, + state::NIL => flag::ANCHOR_NIL, + _ => 0, + }; + f |= match l.ad_backsync.state[i] { + state::ABSENT => flag::BACK_ABSENT, + state::PRESENT => 0, + _ => flag::BACK_MALFORMED, + }; + out.ad[i] = f; + } + for j in 0..l.entra.len() { + let mut f = 0; + if l.entra.scope[j] == UNBOUND { + f |= flag::E_UNBOUND_SCOPE; + } + f |= match l.entra_anchor.state[j] { + state::ABSENT => flag::E_ANCHOR_ABSENT, + state::PRESENT => 0, + _ => flag::E_ANCHOR_FAULT, + }; + out.entra[j] = f; + } + for x in 0..l.exo.len() { + let mut f = 0; + if l.exo.scope[x] == UNBOUND { + f |= flag::X_UNBOUND_SCOPE; + } + f |= match l.exo_external.state[x] { + state::ABSENT => flag::X_ABSENT, + state::PRESENT => 0, + _ => flag::X_FAULT, + }; + out.exo[x] = f; + } + + let edge = |ev, src: (SchemaFamily, Guid128), dst: (SchemaFamily, Guid128)| DirEdge { + kind: EdgeKind::SynchronizesTo, + evidence: ev, + src, + dst, + }; + let fwd_ev = if l.anchor_is_object_guid { + EdgeEvidence::ImmutableIdIsObjectGuid + } else { + EdgeEvidence::SourceAnchorMatchesImmutableId + }; + + // Forward: AD anchor = Entra immutable id. + merge(&ix.ad_by_anchor, &ix.entra_by_anchor, |a, e| { + let shared = if a.len() > 1 { flag::ANCHOR_SHARED } else { 0 }; + let fwd = count_flag(e.len(), flag::FWD_NONE, flag::FWD_ONE, flag::FWD_MANY); + for x in a { + let i = x.row as usize; + out.ad[i] |= fwd | shared; + out.ad_target[i] = sole(e); + for y in e { + out.edges.push(edge( + fwd_ev, + (SchemaFamily::AdDs, l.ad.owner[i]), + (SchemaFamily::MsGraph, l.entra.owner[y.row as usize]), + )); + } + } + let claimed = count_flag( + a.len(), + flag::E_ORPHAN, + flag::E_CLAIMED_ONE, + flag::E_CLAIMED_MANY, + ); + let dup = if e.len() > 1 { + flag::E_ANCHOR_SHARED + } else { + 0 + }; + for y in e { + let j = y.row as usize; + out.entra[j] |= claimed | dup; + out.entra_claimer[j] = sole(a); + } + }); + + // Backsync: AD msDS-ExternalDirectoryObjectId = Entra id. + merge(&ix.ad_by_backsync, &ix.entra_by_id, |a, e| { + let back = count_flag( + e.len(), + flag::BACK_DANGLING, + flag::BACK_ONE, + flag::BACK_MANY, + ); + for x in a { + let i = x.row as usize; + out.ad[i] |= back; + out.ad_back[i] = sole(e); + for y in e { + out.edges.push(edge( + EdgeEvidence::AdBacksyncMatchesEntraObjectId, + (SchemaFamily::AdDs, l.ad.owner[i]), + (SchemaFamily::MsGraph, l.entra.owner[y.row as usize]), + )); + } + } + let named = count_flag(a.len(), 0, flag::E_BACK_ONE, flag::E_BACK_MANY); + for y in e { + let j = y.row as usize; + out.entra[j] |= named; + out.entra_namer[j] = sole(a); + } + }); + + // Cloud: Exchange Online ExternalDirectoryObjectId = Entra id. + merge(&ix.exo_by_external, &ix.entra_by_id, |x, e| { + let hit = count_flag(e.len(), flag::X_DANGLING, flag::X_ONE, flag::X_MANY); + for m in x { + out.exo[m.row as usize] |= hit; + for y in e { + out.edges.push(edge( + EdgeEvidence::EntraObjectIdMatchesExchangeExternalId, + (SchemaFamily::MsGraph, l.entra.owner[y.row as usize]), + (SchemaFamily::ExchangeOnline, l.exo.owner[m.row as usize]), + )); + } + } + let mbx = count_flag(x.len(), 0, flag::E_EXO_ONE, flag::E_EXO_MANY); + for y in e { + out.entra[y.row as usize] |= mbx; + } + }); + + // A contested Entra row: several AD rows claim or name it, or the one + // claimer and the one namer differ. + for j in 0..l.entra.len() { + let (c, n) = (out.entra_claimer[j], out.entra_namer[j]); + let many = out.entra[j] & (flag::E_CLAIMED_MANY | flag::E_BACK_MANY) != 0; + if many || (c != NO_ROW && n != NO_ROW && c != n) { + out.entra[j] |= flag::E_SPLIT; + } + } + + // What the witnesses say together. + for i in 0..l.ad.len() { + let (target, back) = (out.ad_target[i], out.ad_back[i]); + let mut f = out.ad[i]; + if target != NO_ROW { + if back == target { + f |= flag::AGREE; + } else if f & (flag::BACK_ONE | flag::BACK_MANY | flag::BACK_DANGLING) != 0 { + f |= flag::CONTRADICT; + } + if out.entra[target as usize] & flag::E_SPLIT != 0 { + f |= flag::TARGET_SPLIT; + } + let (t0, t1) = (l.ad.at[i], l.entra.at[target as usize]); + if t0 != 0 && t1 != 0 && (t0 - t1).abs() <= l.max_skew_ms { + f |= flag::TIME_ALIGNED; + } + } else if back != NO_ROW && f & flag::FWD_NONE != 0 { + // The anchor reaches nothing while backsync reaches a row: the + // two witnesses disagree about whether a counterpart exists. + f |= flag::CONTRADICT; + } + if back != NO_ROW && out.entra[back as usize] & flag::E_SPLIT != 0 { + f |= flag::TARGET_SPLIT; + } + out.ad[i] = f; + } +} + +/// What an AD row's flags say, most severe first. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum AdStatus { + /// The scope is not bound to a tenant: nothing is compared. + OutOfScope, + /// The configured anchor is present but malformed or nil. + InvalidSourceAnchor, + /// Another AD row carries the same anchor. + AmbiguousSource, + /// Several Entra rows carry the anchor. + AmbiguousTarget, + /// The witnesses disagree. + Contradictory, + /// Forward and backsync agree, observed at aligned times. + Confirmed, + /// Forward and backsync agree, but the observations are not aligned in + /// time (or a time is unknown): no claim that both held at once. + AgreesAcrossTime, + /// Only the forward anchor witness; no backsync to confirm it. + ForwardOnly, + /// Only the backsync witness; the configured anchor is absent. + BacksyncOnly, + /// The configured anchor is absent and nothing else relates the row. + MissingSourceAnchor, + /// The anchor is present and matches nothing. + Unresolved, +} + +impl AdStatus { + /// Read an AD row's flags. + pub fn of(f: u32) -> Self { + use flag::*; + if f & UNBOUND_SCOPE != 0 { + Self::OutOfScope + } else if f & (ANCHOR_MALFORMED | ANCHOR_NIL) != 0 { + Self::InvalidSourceAnchor + } else if f & ANCHOR_SHARED != 0 { + Self::AmbiguousSource + } else if f & FWD_MANY != 0 { + Self::AmbiguousTarget + } else if f & (CONTRADICT | TARGET_SPLIT) != 0 { + Self::Contradictory + } else if f & AGREE != 0 { + if f & TIME_ALIGNED != 0 { + Self::Confirmed + } else { + Self::AgreesAcrossTime + } + } else if f & FWD_ONE != 0 { + Self::ForwardOnly + } else if f & ANCHOR_ABSENT != 0 { + if f & BACK_ONE != 0 { + Self::BacksyncOnly + } else { + Self::MissingSourceAnchor + } + } else { + Self::Unresolved + } + } +} + +const CONTESTED_AD: u32 = flag::FWD_MANY | flag::ANCHOR_SHARED | flag::BACK_MANY; +const CONTESTED_ENTRA: u32 = flag::E_ANCHOR_SHARED | flag::E_SPLIT; + +/// One population on one ruler: slot `i` of every column is the same user +/// slot (the directory's shared `u16` ordinal space, at most 65,536 slots). +/// [`Guid128::NIL`] marks an empty cell — no value, or no counterpart. +/// +/// With the rulers held together, correspondence needs no join: each +/// witness is a position-wise compare of two columns ([`fold_aligned`]). +/// A join ([`fold`]) is only how unaligned observations are put onto the +/// ruler, once, cold ([`Ruler::from_fold`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Ruler { + /// AD source anchor. + pub ad_anchor: Vec, + /// AD backsync id. + pub ad_backsync: Vec, + /// AD observation time. + pub ad_at: Vec, + /// Entra `onPremisesImmutableId`. + pub entra_anchor: Vec, + /// Entra id. + pub entra_id: Vec, + /// Entra observation time. + pub entra_at: Vec, + /// Exchange Online `ExternalDirectoryObjectId`. + pub exo_external: Vec, + /// [`Profile::max_skew_ms`]. + pub max_skew_ms: i64, +} + +impl Ruler { + /// Slot count. + pub fn len(&self) -> usize { + self.ad_anchor.len() + } + /// No slots. + pub fn is_empty(&self) -> bool { + self.ad_anchor.is_empty() + } + + /// Cold: lay a joined population on the ruler, slot = AD row. Only an + /// unambiguous counterpart is placed (exactly one forward target, or + /// failing that exactly one backsync target; exactly one mailbox); + /// anything else leaves the cell empty, so ambiguity can never be read + /// back as agreement. + pub fn from_fold(l: &Lanes, ix: &Index, out: &Output) -> Self { + let n = l.ad.len(); + let mut r = Self { + ad_anchor: vec![Guid128::NIL; n], + ad_backsync: vec![Guid128::NIL; n], + ad_at: l.ad.at.clone(), + entra_anchor: vec![Guid128::NIL; n], + entra_id: vec![Guid128::NIL; n], + entra_at: vec![0; n], + exo_external: vec![Guid128::NIL; n], + max_skew_ms: l.max_skew_ms, + }; + // The one mailbox of each Entra row, if exactly one. + let mut mailbox = vec![NO_ROW; l.entra.len()]; + merge(&ix.exo_by_external, &ix.entra_by_id, |x, e| { + for y in e { + mailbox[y.row as usize] = sole(x); + } + }); + for i in 0..n { + if l.ad_anchor.state[i] == state::PRESENT && out.ad[i] & flag::ANCHOR_SHARED == 0 { + r.ad_anchor[i] = l.ad_anchor.id[i]; + } + if l.ad_backsync.state[i] == state::PRESENT { + r.ad_backsync[i] = l.ad_backsync.id[i]; + } + let j = match out.ad_target[i] { + NO_ROW => out.ad_back[i], + t => t, + }; + // A contested counterpart is not placed: a slot on the ruler + // stands for one row on each side, never for a choice among + // several. + if j == NO_ROW || out.ad[i] & CONTESTED_AD != 0 { + continue; + } + let j = j as usize; + if out.entra[j] & CONTESTED_ENTRA != 0 { + continue; + } + if l.entra_anchor.state[j] == state::PRESENT { + r.entra_anchor[i] = l.entra_anchor.id[j]; + } + r.entra_id[i] = l.entra.owner[j]; + r.entra_at[i] = l.entra.at[j]; + if mailbox[j] != NO_ROW { + r.exo_external[i] = l.exo_external.id[mailbox[j] as usize]; + } + } + r + } +} + +/// Bit planes over the ruler's slots (bit `i % 64` of word `i / 64`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Planes { + /// AD anchor = Entra immutable id, both non-empty. + pub forward: Vec, + /// AD backsync = Entra id, both non-empty. + pub backsync: Vec, + /// Entra id = Exchange Online external id, both non-empty. + pub cloud: Vec, + /// Both observation times known and within the skew. + pub aligned: Vec, + /// Both sides of the forward witness non-empty, but different. + pub forward_conflict: Vec, + /// Both sides of the backsync witness non-empty, but different. + pub backsync_conflict: Vec, +} + +impl Planes { + /// Allocate for a ruler. + pub fn for_ruler(r: &Ruler) -> Self { + let w = r.len().div_ceil(64); + Self { + forward: vec![0; w], + backsync: vec![0; w], + cloud: vec![0; w], + aligned: vec![0; w], + forward_conflict: vec![0; w], + backsync_conflict: vec![0; w], + } + } + + /// Confirmed: forward and backsync agree at aligned times. + pub fn confirmed(&self, word: usize) -> u64 { + self.forward[word] & self.backsync[word] & self.aligned[word] + } +} + +/// Hot: every witness as a position-wise compare. No join, no index, no +/// allocation; `out` must come from [`Planes::for_ruler`]. +pub fn fold_aligned(r: &Ruler, out: &mut Planes) { + let n = r.len(); + for w in 0..n.div_ceil(64) { + let (lo, hi) = (w * 64, ((w + 1) * 64).min(n)); + let (mut fw, mut bk, mut cl, mut al, mut fc, mut bc) = (0u64, 0, 0, 0, 0, 0); + for i in lo..hi { + let sh = i - lo; + let (a, e) = (k(&r.ad_anchor[i]), k(&r.entra_anchor[i])); + let both = (a != 0) & (e != 0); + fw |= ((both & (a == e)) as u64) << sh; + fc |= ((both & (a != e)) as u64) << sh; + let (b, id) = (k(&r.ad_backsync[i]), k(&r.entra_id[i])); + let both = (b != 0) & (id != 0); + bk |= ((both & (b == id)) as u64) << sh; + bc |= ((both & (b != id)) as u64) << sh; + let x = k(&r.exo_external[i]); + cl |= (((x != 0) & (id != 0) & (x == id)) as u64) << sh; + let (t0, t1) = (r.ad_at[i], r.entra_at[i]); + al |= (((t0 != 0) & (t1 != 0) & ((t0 - t1).abs() <= r.max_skew_ms)) as u64) << sh; + } + out.forward[w] = fw; + out.backsync[w] = bk; + out.cloud[w] = cl; + out.aligned[w] = al; + out.forward_conflict[w] = fc; + out.backsync_conflict[w] = bc; + } +} diff --git a/crates/ogar-dir-core/src/edge.rs b/crates/ogar-dir-core/src/edge.rs index 5e73cdbb..97729dcc 100644 --- a/crates/ogar-dir-core/src/edge.rs +++ b/crates/ogar-dir-core/src/edge.rs @@ -17,7 +17,9 @@ //! //! The vocabulary is deliberately minimal: only [`EdgeKind::SynchronizesTo`] //! exists. `MEMBER_OF` / `MANAGER` are expected later and get the next codes; -//! they are not defined until a slice actually emits them. +//! they are not defined until a slice actually emits them. The evidence code +//! says which witness asserted an edge; an edge is a witness, never a +//! decision, so two witnesses that disagree both stay. //! //! lance-graph's `CausalEdge64` was checked and does not fit: it packs a //! reasoning edge into 8 bytes and cannot carry two 128-bit endpoints. @@ -43,7 +45,34 @@ pub enum EdgeKind { pub enum EdgeEvidence { /// Graph `onPremisesImmutableId` base64-decodes to 16 bytes equal (as a /// Microsoft mixed-endian GUID) to an observed AD `objectGUID`. + /// + /// Meaning unchanged since code 1 was assigned: only the `objectGUID` + /// anchor profile asserts it. A `mS-DS-ConsistencyGuid` match is + /// [`Self::SourceAnchorMatchesImmutableId`]. ImmutableIdIsObjectGuid = 1, + /// Graph `onPremisesImmutableId` decodes to the AD object's + /// `mS-DS-ConsistencyGuid` (the configured source anchor), which may + /// differ from its `objectGUID`. + SourceAnchorMatchesImmutableId = 2, + /// AD `msDS-ExternalDirectoryObjectId` (label stripped) equals the Entra + /// object id: the backsync witness. + AdBacksyncMatchesEntraObjectId = 3, + /// Exchange Online `ExternalDirectoryObjectId` equals the Entra object + /// id. Direction: Entra → Exchange Online. + EntraObjectIdMatchesExchangeExternalId = 4, +} + +impl EdgeEvidence { + /// Decode, `None` for unknown codes. + pub fn from_u16(v: u16) -> Option { + match v { + 1 => Some(Self::ImmutableIdIsObjectGuid), + 2 => Some(Self::SourceAnchorMatchesImmutableId), + 3 => Some(Self::AdBacksyncMatchesEntraObjectId), + 4 => Some(Self::EntraObjectIdMatchesExchangeExternalId), + _ => None, + } + } } /// One edge. @@ -79,10 +108,7 @@ impl DirEdge { 1 => EdgeKind::SynchronizesTo, _ => return None, }; - let evidence = match u(2) { - 1 => EdgeEvidence::ImmutableIdIsObjectGuid, - _ => return None, - }; + let evidence = EdgeEvidence::from_u16(u(2))?; Some(Self { kind, evidence, @@ -97,3 +123,33 @@ impl DirEdge { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_evidence_code_round_trips_and_code_1_keeps_its_value() { + assert_eq!(EdgeEvidence::ImmutableIdIsObjectGuid as u16, 1); + for ev in [ + EdgeEvidence::ImmutableIdIsObjectGuid, + EdgeEvidence::SourceAnchorMatchesImmutableId, + EdgeEvidence::AdBacksyncMatchesEntraObjectId, + EdgeEvidence::EntraObjectIdMatchesExchangeExternalId, + ] { + let e = DirEdge { + kind: EdgeKind::SynchronizesTo, + evidence: ev, + src: (SchemaFamily::MsGraph, Guid128([1; 16])), + dst: (SchemaFamily::ExchangeOnline, Guid128([2; 16])), + }; + assert_eq!(DirEdge::from_bytes(&e.to_bytes()), Some(e)); + } + let mut b = [0u8; EDGE_BYTES]; + b[0] = 1; + b[2] = 5; // unknown evidence + b[4] = 1; + b[6] = 2; + assert_eq!(DirEdge::from_bytes(&b), None); + } +} diff --git a/crates/ogar-dir-core/src/identity.rs b/crates/ogar-dir-core/src/identity.rs new file mode 100644 index 00000000..67c155c9 --- /dev/null +++ b/crates/ogar-dir-core/src/identity.rs @@ -0,0 +1,428 @@ +//! Identity rules: how one system's spelling of an id relates to another's. +//! +//! An [`IdentityRule`] is a declaration, not code: *this field on this +//! family, in this external encoding, names the same 128-bit id as that +//! field on that family, in that encoding*. The encoding is applied once at +//! the boundary ([`Encoding::decode`]); afterwards both sides are +//! [`Guid128`] and compare as 16 bytes ([`Compare::Canonical128`]). +//! +//! A rule has two forms: +//! +//! * the declaration ([`IdentityRule`]) — attribute names, readable; +//! * the compiled descriptor ([`CompiledRule`]) — the same rule with every +//! name resolved to a numeric slot of a schema table. Only this form is +//! used to build lanes; no name is looked up per row. +//! +//! The rules here are the hybrid-identity correspondences. The source +//! anchor is a deployment choice: [`CONSISTENCY_GUID_TO_IMMUTABLE_ID`] when +//! `mS-DS-ConsistencyGuid` is the anchor, [`OBJECT_GUID_TO_IMMUTABLE_ID`] +//! when `objectGUID` is. They are different rules; one never falls back to +//! the other. + +use crate::base64; +use crate::guid::Guid128; +use crate::label::LabelPattern; +use crate::record::{DirRecord, STR_SLOTS}; +use crate::schema::{AttrDef, AttrKind, SchemaFamily}; + +/// How a system spells a 128-bit id on the wire. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Encoding { + /// 16 raw bytes, Microsoft mixed-endian (`objectGUID`, + /// `mS-DS-ConsistencyGuid`). + MsGuidBytes16, + /// Padded base64 of those 16 bytes (`onPremisesImmutableId`). + Base64MsGuidBytes16, + /// A label around the textual GUID (`User_{0}`). + Labeled(LabelPattern), + /// The textual GUID (`id`, `ExternalDirectoryObjectId`). + GuidText, +} + +/// Why a value is not an id in its declared encoding. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum IdFault { + /// Present, but not the encoding (wrong length, bad character, wrong + /// label, a spelling that does not render back). + Malformed, + /// Well-formed, but the all-zero id, which names no object. + Nil, +} + +impl Encoding { + /// Decode a value to its id. Strict: only a spelling that renders back + /// to exactly `raw` is accepted, so two different spellings can never + /// decode to one id. + pub fn decode(self, raw: &[u8]) -> Result { + let g = match self { + Self::MsGuidBytes16 => Guid128::from_ms_bytes(raw).map_err(|_| IdFault::Malformed)?, + Self::Base64MsGuidBytes16 => { + let s = std::str::from_utf8(raw).map_err(|_| IdFault::Malformed)?; + let bytes = base64::decode(s).ok_or(IdFault::Malformed)?; + let g = Guid128::from_ms_bytes(&bytes).map_err(|_| IdFault::Malformed)?; + if base64::encode(&bytes) != s { + return Err(IdFault::Malformed); + } + g + } + Self::Labeled(p) => { + let s = std::str::from_utf8(raw).map_err(|_| IdFault::Malformed)?; + // `strip` already refuses nil and non-canonical spellings; + // tell the two apart. + match p.strip(s) { + Some(g) => g, + None if p.strip_any(s) == Some(Guid128::NIL) => return Err(IdFault::Nil), + None => return Err(IdFault::Malformed), + } + } + Self::GuidText => { + let s = std::str::from_utf8(raw).map_err(|_| IdFault::Malformed)?; + let g = Guid128::parse(s).map_err(|_| IdFault::Malformed)?; + if g.to_string() != s { + return Err(IdFault::Malformed); + } + g + } + }; + if g.is_nil() { Err(IdFault::Nil) } else { Ok(g) } + } + + /// Spell an id in this encoding (egress only). + pub fn encode(self, id: Guid128) -> Vec { + match self { + Self::MsGuidBytes16 => id.to_ms_bytes().to_vec(), + Self::Base64MsGuidBytes16 => base64::encode(&id.to_ms_bytes()).into_bytes(), + Self::Labeled(p) => p.render(id).into_bytes(), + Self::GuidText => id.to_string().into_bytes(), + } + } +} + +/// How the two decoded sides compare. There is one way: the 16 bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Compare { + /// Byte equality of the canonical [`Guid128`]; no text, no hash. + Canonical128, +} + +/// Which value of an object a rule reads. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Field { + /// The object's own id (AD `objectGUID`, Graph `id`). + Node, + /// A schema attribute, by its source-native name. + Attr(&'static str), +} + +/// One side of a rule. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Endpoint { + /// The source system. + pub family: SchemaFamily, + /// The value read. + pub field: Field, + /// How the source spells it. + pub encoding: Encoding, +} + +/// A declared identity correspondence. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct IdentityRule { + /// Reported name (diagnostics only). + pub name: &'static str, + /// Where the id comes from. + pub source: Endpoint, + /// Where it is expected again. + pub target: Endpoint, + /// How the two compare. + pub compare_as: Compare, +} + +const fn ep(family: SchemaFamily, field: Field, encoding: Encoding) -> Endpoint { + Endpoint { + family, + field, + encoding, + } +} + +/// Source anchor `mS-DS-ConsistencyGuid` → cloud `onPremisesImmutableId`. +pub const CONSISTENCY_GUID_TO_IMMUTABLE_ID: IdentityRule = IdentityRule { + name: "mS-DS-ConsistencyGuid -> onPremisesImmutableId", + source: ep( + SchemaFamily::AdDs, + Field::Attr("mS-DS-ConsistencyGuid"), + Encoding::MsGuidBytes16, + ), + target: ep( + SchemaFamily::MsGraph, + Field::Attr("onPremisesImmutableId"), + Encoding::Base64MsGuidBytes16, + ), + compare_as: Compare::Canonical128, +}; + +/// Source anchor `objectGUID` → cloud `onPremisesImmutableId`, for +/// deployments that anchor on `objectGUID`. Not a fallback of +/// [`CONSISTENCY_GUID_TO_IMMUTABLE_ID`]. +pub const OBJECT_GUID_TO_IMMUTABLE_ID: IdentityRule = IdentityRule { + name: "objectGUID -> onPremisesImmutableId", + source: ep(SchemaFamily::AdDs, Field::Node, Encoding::MsGuidBytes16), + target: ep( + SchemaFamily::MsGraph, + Field::Attr("onPremisesImmutableId"), + Encoding::Base64MsGuidBytes16, + ), + compare_as: Compare::Canonical128, +}; + +/// Backsync: AD `msDS-ExternalDirectoryObjectId` (`User_`) → Entra `id`. +pub const BACKSYNC_TO_ENTRA_ID: IdentityRule = IdentityRule { + name: "msDS-ExternalDirectoryObjectId -> id", + source: ep( + SchemaFamily::AdDs, + Field::Attr("msDS-ExternalDirectoryObjectId"), + Encoding::Labeled(LabelPattern("User_{0}")), + ), + target: ep(SchemaFamily::MsGraph, Field::Node, Encoding::GuidText), + compare_as: Compare::Canonical128, +}; + +/// Entra `id` → Exchange Online `ExternalDirectoryObjectId`. +pub const ENTRA_ID_TO_EXO_EXTERNAL_ID: IdentityRule = IdentityRule { + name: "id -> ExternalDirectoryObjectId", + source: ep(SchemaFamily::MsGraph, Field::Node, Encoding::GuidText), + target: ep( + SchemaFamily::ExchangeOnline, + Field::Attr("ExternalDirectoryObjectId"), + Encoding::GuidText, + ), + compare_as: Compare::Canonical128, +}; + +/// Where a compiled side reads its value in a [`DirRecord`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Slot { + /// `node_guid` (already canonical). + Node, + /// An inline guid slot (decoded at ingest, already canonical). + Guid(u8), + /// A pooled string slot holding the external spelling; decoded with the + /// side's [`Encoding`] when the lane is built. + Pooled(u8), +} + +/// One compiled side: numeric only. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Selector { + /// Family the record must carry. + pub family: SchemaFamily, + /// Where the value is. + pub slot: Slot, + /// The external encoding (applied only for [`Slot::Pooled`]). + pub encoding: Encoding, +} + +/// A rule with every name resolved against the schema tables. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CompiledRule { + /// Source side. + pub source: Selector, + /// Target side. + pub target: Selector, +} + +/// Why a rule does not compile against the given tables. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CompileError { + /// The attribute is not in the table. + Unknown(&'static str), + /// The attribute is stored in a way the encoding cannot be read from + /// (a multi-valued slot, a numeric slot, raw bytes for a text encoding). + Incompatible(&'static str), +} + +fn select(e: Endpoint, table: &[AttrDef]) -> Result { + let slot = match e.field { + Field::Node => Slot::Node, + Field::Attr(name) => { + let d = table + .iter() + .find(|d| d.name == name) + .ok_or(CompileError::Unknown(name))?; + match (d.kind, e.encoding) { + (AttrKind::Guid, _) => Slot::Guid(d.slot), + (AttrKind::Str, Encoding::MsGuidBytes16) => { + return Err(CompileError::Incompatible(name)); + } + (AttrKind::Str, _) | (AttrKind::Bytes, Encoding::MsGuidBytes16) => { + Slot::Pooled(d.slot) + } + _ => return Err(CompileError::Incompatible(name)), + } + } + }; + Ok(Selector { + family: e.family, + slot, + encoding: e.encoding, + }) +} + +impl IdentityRule { + /// Resolve both sides against their families' schema tables. + pub fn compile( + &self, + source_table: &[AttrDef], + target_table: &[AttrDef], + ) -> Result { + Ok(CompiledRule { + source: select(self.source, source_table)?, + target: select(self.target, target_table)?, + }) + } +} + +/// What a record holds for one selector. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum IdValue { + /// No value (presence bit clear). + Absent, + /// A value that is not an id in the declared encoding. + Fault(IdFault), + /// The id. + Id(Guid128), +} + +impl Selector { + /// Read the value from a record (boundary: may decode a pooled string). + /// A record of another family is [`IdValue::Absent`]. + pub fn read(&self, rec: &DirRecord, pool: &crate::ValuePool) -> IdValue { + if rec.schema().family != self.family { + return IdValue::Absent; + } + let g = match self.slot { + Slot::Node => Some(rec.node_guid()), + Slot::Guid(s) => rec.guid(s as usize), + Slot::Pooled(s) if (s as usize) < STR_SLOTS => { + let Some(r) = rec.str_ref(s as usize) else { + return IdValue::Absent; + }; + return match pool.get(r) { + None => IdValue::Fault(IdFault::Malformed), + Some(b) => match self.encoding.decode(b) { + Ok(g) => IdValue::Id(g), + Err(f) => IdValue::Fault(f), + }, + }; + } + Slot::Pooled(_) => None, + }; + match g { + None => IdValue::Absent, + Some(g) if g.is_nil() => IdValue::Fault(IdFault::Nil), + Some(g) => IdValue::Id(g), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ID: &str = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; + const MS: [u8; 16] = [ + 0xe0, 0x04, 0x25, 0x3f, 0x89, 0x4f, 0xd3, 0x11, 0x9a, 0x0c, 0x03, 0x05, 0xe8, 0x2c, 0x33, + 0x01, + ]; + const B64: &str = "4AQlP4lP0xGaDAMF6CwzAQ=="; + + #[test] + fn every_encoding_round_trips_the_known_vector() { + let g = Guid128::parse(ID).unwrap(); + for (enc, wire) in [ + (Encoding::MsGuidBytes16, MS.to_vec()), + (Encoding::Base64MsGuidBytes16, B64.as_bytes().to_vec()), + ( + Encoding::Labeled(LabelPattern("User_{0}")), + format!("User_{ID}").into_bytes(), + ), + (Encoding::GuidText, ID.as_bytes().to_vec()), + ] { + assert_eq!(enc.decode(&wire), Ok(g), "{enc:?}"); + assert_eq!(enc.encode(g), wire, "{enc:?}"); + } + } + + /// F9: reading the mixed-endian bytes as if they were textual order is + /// a different, equally valid-looking id. + #[test] + fn byte_order_is_not_a_no_op() { + let right = Encoding::MsGuidBytes16.decode(&MS).unwrap(); + assert_ne!(right, Guid128(MS)); + assert_eq!(right.to_string(), ID); + } + + #[test] + fn malformed_and_nil_are_different_faults() { + let labeled = Encoding::Labeled(LabelPattern("User_{0}")); + for (enc, wire, fault) in [ + (Encoding::MsGuidBytes16, vec![0u8; 15], IdFault::Malformed), + (Encoding::MsGuidBytes16, vec![0u8; 16], IdFault::Nil), + ( + Encoding::Base64MsGuidBytes16, + b"4AQlP4lP0xGaDAMF6CwzAQ".to_vec(), // unpadded + IdFault::Malformed, + ), + ( + Encoding::Base64MsGuidBytes16, + b"AAAAAAAAAAAAAAAAAAAAAA==".to_vec(), + IdFault::Nil, + ), + ( + Encoding::Base64MsGuidBytes16, + b"4AQlP4lP0xGaDAMF6Cwz".to_vec(), // 15 bytes + IdFault::Malformed, + ), + ( + labeled, + format!("Group_{ID}").into_bytes(), + IdFault::Malformed, + ), + ( + labeled, + format!("User_{}", ID.to_uppercase()).into_bytes(), + IdFault::Malformed, + ), + ( + labeled, + b"User_00000000-0000-0000-0000-000000000000".to_vec(), + IdFault::Nil, + ), + ( + Encoding::GuidText, + ID.to_uppercase().into_bytes(), + IdFault::Malformed, + ), + ( + Encoding::GuidText, + b"00000000-0000-0000-0000-000000000000".to_vec(), + IdFault::Nil, + ), + ] { + assert_eq!(enc.decode(&wire), Err(fault), "{enc:?} {wire:?}"); + } + } + + #[test] + fn the_two_anchor_rules_read_different_fields() { + assert_ne!( + CONSISTENCY_GUID_TO_IMMUTABLE_ID.source.field, + OBJECT_GUID_TO_IMMUTABLE_ID.source.field + ); + assert_eq!( + CONSISTENCY_GUID_TO_IMMUTABLE_ID.target, + OBJECT_GUID_TO_IMMUTABLE_ID.target + ); + } +} diff --git a/crates/ogar-dir-core/src/label.rs b/crates/ogar-dir-core/src/label.rs index 84512ef9..afa64d71 100644 --- a/crates/ogar-dir-core/src/label.rs +++ b/crates/ogar-dir-core/src/label.rs @@ -30,9 +30,15 @@ impl LabelPattern { /// around a non-nil id in its canonical spelling (lower-case hex, no /// braces), so that rendering it gives `s` back. pub fn strip(self, s: &str) -> Option { + self.strip_any(s).filter(|id| !id.is_nil()) + } + + /// [`Self::strip`] without the nil check, so a caller can tell a nil + /// id in the canonical spelling from a malformed value. + pub(crate) fn strip_any(self, s: &str) -> Option { let (pre, post) = self.parts()?; let id = Guid128::parse(s.strip_prefix(pre)?.strip_suffix(post)?).ok()?; - (!id.is_nil() && self.render(id) == s).then_some(id) + (self.render(id) == s).then_some(id) } /// Add the label: the template with `{0}` replaced by the id. diff --git a/crates/ogar-dir-core/src/lib.rs b/crates/ogar-dir-core/src/lib.rs index 38aa6ff8..821bed77 100644 --- a/crates/ogar-dir-core/src/lib.rs +++ b/crates/ogar-dir-core/src/lib.rs @@ -27,11 +27,13 @@ //! workspace for the conflict note. pub mod base64; +pub mod correspond; pub mod dn; pub mod dn128; pub mod edge; pub mod guid; pub mod hhtl; +pub mod identity; pub mod label; pub mod pool; pub mod record; @@ -43,6 +45,7 @@ pub use dn128::{DN_CHILDREN, DN_LEVELS, DirectoryScope, Dn128, Dn128Error}; pub use edge::{DirEdge, EdgeEvidence, EdgeKind}; pub use guid::{Guid128, GuidParseError}; pub use hhtl::{HhtlError, OU_LEVELS, OuDictionary, OuHhtl}; +pub use identity::{CompiledRule, Encoding, Field, IdFault, IdValue, IdentityRule, Selector, Slot}; pub use label::{CloudLabel, LabelPattern}; pub use pool::{PoolError, StrRef, ValuePool}; pub use record::{ABI_MAJOR, ABI_MINOR, DirRecord, GUID_SLOTS, RECORD_BYTES, RecordError}; diff --git a/crates/ogar-dir-core/src/record.rs b/crates/ogar-dir-core/src/record.rs index e3584513..db9f9398 100644 --- a/crates/ogar-dir-core/src/record.rs +++ b/crates/ogar-dir-core/src/record.rs @@ -11,7 +11,7 @@ //! 0x020..0x024 magic [4] b"OGDR" //! 0x024..0x026 abi_major u16 ABI_MAJOR (exact match required) //! 0x026..0x028 abi_minor u16 ABI_MINOR (additive; readers ignore newer) -//! 0x028..0x02A schema_family u16 SchemaFamily (1 = AD_DS, 2 = MS_GRAPH) +//! 0x028..0x02A schema_family u16 SchemaFamily (1 = AD_DS, 2 = MS_GRAPH, 3 = EXO) //! 0x02A..0x02C schema_version u16 encoder's schema version //! 0x02C..0x02E object_kind u16 family-scoped kind code //! 0x02E..0x030 flags u16 FLAG_* below diff --git a/crates/ogar-dir-core/src/schema.rs b/crates/ogar-dir-core/src/schema.rs index c47ab8da..08b57f2d 100644 --- a/crates/ogar-dir-core/src/schema.rs +++ b/crates/ogar-dir-core/src/schema.rs @@ -24,6 +24,8 @@ pub enum SchemaFamily { AdDs = 1, /// Microsoft Graph / Entra ID. MsGraph = 2, + /// Exchange Online (a recipient as `Get-Recipient` reports it). + ExchangeOnline = 3, } impl SchemaFamily { @@ -32,6 +34,7 @@ impl SchemaFamily { match v { 1 => Some(Self::AdDs), 2 => Some(Self::MsGraph), + 3 => Some(Self::ExchangeOnline), _ => None, } } diff --git a/crates/ogar-dir-sim/src/identity.rs b/crates/ogar-dir-sim/src/identity.rs index 6e5d8e37..505899e1 100644 --- a/crates/ogar-dir-sim/src/identity.rs +++ b/crates/ogar-dir-sim/src/identity.rs @@ -24,7 +24,7 @@ //! Decoding is strict, like [`crate::exchange`]: a value that does not //! re-render to exactly what was observed is not decoded. -use ogar_dir_core::{Guid128, base64, label}; +use ogar_dir_core::{Encoding, Guid128, base64, label}; pub use ogar_dir_core::label::CloudLabel; @@ -69,8 +69,10 @@ impl SourceAnchor { /// From a cloud `ImmutableId`. Only the padded form this crate renders /// is accepted, so the decode is exact. pub fn from_immutable_id(s: &str) -> Option { - let a = Self::from_consistency_guid(&base64::decode(s)?)?; - (a.immutable_id() == s).then_some(a) + Encoding::Base64MsGuidBytes16 + .decode(s.as_bytes()) + .ok() + .map(Self) } /// The raw attribute value.