From b15265d59be6b299553d52e1232af917a46daa85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:53:34 +0000 Subject: [PATCH 1/2] ogar-dir-sim: deprecate AddressRole::Mail `mail` is a display label Exchange does not reserve; only proxyAddresses, the UPN and a remote mailbox's routing address make an address belong to an object. lance-graph's validator stopped emitting the Mail role (lance-graph #1433), so the variant has no producer left. Deprecate it rather than remove it so existing matches keep compiling, and correct the AddressConflict doc that still described a `mail` value as a claim. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-dir-sim/src/violation.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/ogar-dir-sim/src/violation.rs b/crates/ogar-dir-sim/src/violation.rs index 22a2ef42..de643be5 100644 --- a/crates/ogar-dir-sim/src/violation.rs +++ b/crates/ogar-dir-sim/src/violation.rs @@ -26,6 +26,14 @@ pub enum AddressRole { /// A secondary SMTP proxy (`smtp:`). SecondarySmtp, /// The `mail` attribute. + /// + /// No validator emits this role. `mail` is a display label that + /// Exchange does not reserve: two objects may carry the same `mail` + /// value, and only `proxyAddresses`, the UPN and a remote mailbox's + /// routing address make an address belong to an object. The variant + /// stays so existing matches keep compiling; new code must not + /// produce it. + #[deprecated(note = "`mail` is a label, not an address claim; no validator emits it")] Mail, /// A remote mailbox's routing address (`targetAddress`). Routing, @@ -49,10 +57,10 @@ pub enum Violation { owners: Vec, }, /// Two or more objects hold the same normalized address under different - /// attributes: a UPN or SMTP address that is another object's SMTP, - /// `mail` or routing address, or a `mail` held by another object (an - /// admin account whose `mail` points at someone's mailbox as a - /// password-reset target, for example). Same-attribute collisions are + /// attributes: a UPN or SMTP address that is another object's SMTP or + /// routing address. A `mail` value is not a claim (an admin account + /// whose `mail` points at someone's mailbox as a password-reset target + /// holds nothing), so it never appears here. Same-attribute collisions are /// [`Violation::DuplicateSmtp`] / [`Violation::DuplicateUpn`]; one object /// holding an address under several attributes is not a conflict. AddressConflict { From 89991e25e324abe386e1ca91affef38400dc74bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 10:33:21 +0000 Subject: [PATCH 2/2] ogar-dir-sim: repurpose Mail as a label that hydrates the Exchange identity Deprecating AddressRole::Mail treated "no longer a claim" as "carries no information". It is still information: a `mail` label names an address, and the useful question is which object holds that address and what Exchange knows it as. - AddressRole::Mail is the label role again, not deprecated. It is still never a holder in AddressConflict. - exchange::ExchangeIdentity: what Exchange knows an object as, read by GUID: the recipient types, the mailbox's ExchangeGuid, and its PrimarySmtpAddress. - exchange::MailLabel: where a label resolves. Own (the object holds the address itself), Elsewhere (another object holds it; that holder's identity), Contested (several holders; none hydrated), Unheld (stale). - ogar-ad schema v6: msExchMailboxGuid as a 128-bit id in guid slot 2 (mixed-endian, like objectGUID), with exchange_guid() for the outbound bytes. Exchange Online keeps the same value as the mailbox's ExchangeGuid across a move. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-ad/src/lib.rs | 21 ++++++++++- crates/ogar-ad/tests/main.rs | 34 +++++++++++++++++ crates/ogar-dir-sim/src/exchange.rs | 56 +++++++++++++++++++++++++++- crates/ogar-dir-sim/src/lib.rs | 5 ++- crates/ogar-dir-sim/src/violation.rs | 15 +++----- 5 files changed, 117 insertions(+), 14 deletions(-) diff --git a/crates/ogar-ad/src/lib.rs b/crates/ogar-ad/src/lib.rs index b884875c..0743660e 100644 --- a/crates/ogar-ad/src/lib.rs +++ b/crates/ogar-ad/src/lib.rs @@ -25,7 +25,7 @@ use ogar_dir_core::{ }; /// Encoder schema version understood by this crate. -pub const SCHEMA_VERSION: u16 = 5; +pub const SCHEMA_VERSION: u16 = 6; /// This crate's schema id. pub const SCHEMA: SchemaId = SchemaId { family: SchemaFamily::AdDs, @@ -64,6 +64,12 @@ pub const SCHEMA: SchemaId = SchemaId { /// v5 adds `extensionAttribute1`..`15` as one positional bag /// ([`AttrKind::Bag`]), the shape Graph returns them in /// (`onPremisesExtensionAttributes`). +/// +/// v6 adds `msExchMailboxGuid`, the mailbox's own GUID (16 mixed-endian +/// bytes, like `objectGUID`), in an inline guid slot. Exchange Online keeps +/// it as the mailbox's `ExchangeGuid` across a move, so it is the mailbox +/// half of an object's Exchange identity (`ogar-dir-sim::ExchangeIdentity`) +/// and, like the source anchor, never text. pub const SCHEMA_V1: &[AttrDef] = &[ AttrDef { name: "distinguishedName", @@ -197,6 +203,12 @@ pub const SCHEMA_V1: &[AttrDef] = &[ kind: AttrKind::Bag, since: 5, }, + AttrDef { + name: "msExchMailboxGuid", + slot: 2, + kind: AttrKind::Guid, + since: 6, + }, ]; /// AD object kinds (family-scoped codes in `object_kind`). @@ -319,7 +331,7 @@ pub fn cloud_label(kind: AdKind) -> Option { /// Inbound: a 128-bit id attribute's source spelling → the id. fn decode_id(name: &str, raw: &[u8], kind: u16) -> Option { let g = match name { - "mS-DS-ConsistencyGuid" => Guid128::from_ms_bytes(raw).ok()?, + "mS-DS-ConsistencyGuid" | "msExchMailboxGuid" => Guid128::from_ms_bytes(raw).ok()?, "msDS-ExternalDirectoryObjectId" => { // The kind's own template: a User_ id on a group does not match. let label = cloud_label(AdKind::from_code(kind)?)?; @@ -351,6 +363,11 @@ pub fn consistency_guid(rec: &DirRecord) -> Option<[u8; 16]> { Some(rec.guid(guid_slot("mS-DS-ConsistencyGuid"))?.to_ms_bytes()) } +/// Outbound: `msExchMailboxGuid` as AD stores it (16 mixed-endian bytes). +pub fn exchange_guid(rec: &DirRecord) -> Option<[u8; 16]> { + Some(rec.guid(guid_slot("msExchMailboxGuid"))?.to_ms_bytes()) +} + /// `base` is a member of a bag attribute (`extensionAttribute7`). fn is_bag_member(base: &str) -> bool { SCHEMA_V1 diff --git a/crates/ogar-ad/tests/main.rs b/crates/ogar-ad/tests/main.rs index fd2ee93e..44937775 100644 --- a/crates/ogar-ad/tests/main.rs +++ b/crates/ogar-ad/tests/main.rs @@ -382,3 +382,37 @@ fn extension_attributes_are_one_bag() { assert_eq!(odd.record.str_ref(slot("extensionAttribute")), None); assert_eq!(odd.ignored, vec!["extensionAttribute01"]); } + +// msExchMailboxGuid is the mailbox's own GUID: stored as a 128-bit id in a +// guid slot (never pooled text) and given back as AD's 16 mixed-endian +// bytes. A value that is not 16 bytes is refused, never stored as text. +#[test] +fn the_exchange_guid_is_an_id_not_a_string() { + const BOX: &str = "Ab5xLqKmTkiZ8gH0cDeFqw=="; + let text = format!( + "dn: CN=X,OU=Staff,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: user\nmsExchMailboxGuid:: {BOX}\n" + ); + let e = &ldif::parse(&text).unwrap()[0]; + let (mut dict, mut pool) = (OuDictionary::new(), ValuePool::new()); + let enc = encode(e, domain(), &mut dict, &mut pool, 1).unwrap(); + assert!(enc.ignored.is_empty(), "{:?}", enc.ignored); + let r = enc.record; + let raw = ogar_dir_core::base64::decode(BOX).unwrap(); + assert_eq!( + r.guid(slot("msExchMailboxGuid")), + Some(Guid128::from_ms_bytes(&raw).unwrap()) + ); + assert_ne!(r.guid(slot("msExchMailboxGuid")), Some(r.node_guid())); + for s in 0..32 { + if let Some(sr) = r.str_ref(s) { + let v = pool.get(sr).unwrap(); + assert!(!v.windows(raw.len()).any(|w| w == raw.as_slice())); + } + } + assert_eq!(ogar_ad::exchange_guid(&r).map(|b| b.to_vec()), Some(raw)); + + let bad = "dn: CN=X,OU=Staff,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: user\nmsExchMailboxGuid:: AAEC\n"; + let e = &ldif::parse(bad).unwrap()[0]; + let (mut dict, mut pool) = (OuDictionary::new(), ValuePool::new()); + assert!(encode(e, domain(), &mut dict, &mut pool, 1).is_err()); +} diff --git a/crates/ogar-dir-sim/src/exchange.rs b/crates/ogar-dir-sim/src/exchange.rs index c0d8efdc..2fa90f46 100644 --- a/crates/ogar-dir-sim/src/exchange.rs +++ b/crates/ogar-dir-sim/src/exchange.rs @@ -23,7 +23,8 @@ //! **The enabled flag is not part of this.** A shared, room or equipment //! mailbox is a disabled account by design and is still a recipient. -use crate::change::ValueId; +use crate::change::{KeyId, ValueId}; +use ogar_dir_core::Guid128; /// `msExchRemoteRecipientType` flag bits. pub mod flag { @@ -334,6 +335,59 @@ impl Recipient { } } +/// What Exchange knows an object as, read by its GUID: its recipient types +/// ([`Recipient`]), its mailbox's `ExchangeGuid` (`msExchMailboxGuid` +/// on-premises; Exchange Online keeps the same value as the mailbox's +/// `ExchangeGuid` across a move) and the address it receives at +/// (`PrimarySmtpAddress`). +/// +/// The identity is `node` and, for a mailbox, `exchange_guid`; the address is +/// a value of the identity, never the key it is found by. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExchangeIdentity { + /// The object (`objectGUID`). + pub node: Guid128, + /// Recipient types; `None` = the source did not read them. + pub recipient: Option, + /// `msExchMailboxGuid`; `None` = no mailbox, or not read. + pub exchange_guid: Option, + /// `PrimarySmtpAddress`. + pub primary_smtp: Option, +} + +/// What an object's `mail` label resolves to. The label is the trigger: its +/// address is looked up once, among the addresses objects actually hold +/// (UPN, `proxyAddresses`, routing address), and the holder's +/// [`ExchangeIdentity`] is hydrated by GUID. The label itself never reserves +/// the address and never decides delivery or access. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MailLabel { + /// The label is an address the labelled object holds itself. + Own(ExchangeIdentity), + /// The label names an address another object holds: an admin account + /// whose `mail` is a user's mailbox (a password-reset target), say. The + /// identity is the holder's. + Elsewhere { + /// The label's comparison key. + key: KeyId, + /// The object that holds the address. + holder: ExchangeIdentity, + }, + /// Several objects hold the address: none is hydrated, the holders are + /// listed (sorted). + Contested { + /// The label's comparison key. + key: KeyId, + /// Every holder. + holders: Vec, + }, + /// No object holds the address: a stale label. + Unheld { + /// The label's comparison key. + key: KeyId, + }, +} + /// One step of the hybrid remote-mailbox lifecycle (semantic; the actuator /// maps it to its own commands). #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] diff --git a/crates/ogar-dir-sim/src/lib.rs b/crates/ogar-dir-sim/src/lib.rs index 08d129fb..a2374eed 100644 --- a/crates/ogar-dir-sim/src/lib.rs +++ b/crates/ogar-dir-sim/src/lib.rs @@ -26,8 +26,9 @@ pub use change::{ Attribute, Change, KeyId, NodeKind, NodeState, Refusal, ValueId, effective_active, normalize, }; pub use exchange::{ - AddressTemplate, ArchiveState, LifecycleRefusal, MailboxState, ROUTING, Recipient, - RecipientAttributes, RemoteKind, RemoteMailbox, RemoteMailboxOp, routing_parts, + AddressTemplate, ArchiveState, ExchangeIdentity, LifecycleRefusal, MailLabel, MailboxState, + ROUTING, Recipient, RecipientAttributes, RemoteKind, RemoteMailbox, RemoteMailboxOp, + routing_parts, }; pub use identity::{CloudLabel, ExternalObjectId, SourceAnchor}; pub use plan::{ExecutionPlan, Operation, PlanError, PlannedOp, Precondition}; diff --git a/crates/ogar-dir-sim/src/violation.rs b/crates/ogar-dir-sim/src/violation.rs index de643be5..5ad34377 100644 --- a/crates/ogar-dir-sim/src/violation.rs +++ b/crates/ogar-dir-sim/src/violation.rs @@ -25,15 +25,12 @@ pub enum AddressRole { PrimarySmtp, /// A secondary SMTP proxy (`smtp:`). SecondarySmtp, - /// The `mail` attribute. - /// - /// No validator emits this role. `mail` is a display label that - /// Exchange does not reserve: two objects may carry the same `mail` - /// value, and only `proxyAddresses`, the UPN and a remote mailbox's - /// routing address make an address belong to an object. The variant - /// stays so existing matches keep compiling; new code must not - /// produce it. - #[deprecated(note = "`mail` is a label, not an address claim; no validator emits it")] + /// The `mail` attribute: a label, never a claim. Exchange does not + /// reserve it, so it never makes an object a holder in + /// [`Violation::AddressConflict`]. A label is a hydration trigger: it is + /// resolved to the object that does hold the address it names, and that + /// object's [`ExchangeIdentity`](crate::exchange::ExchangeIdentity) is + /// read by GUID ([`MailLabel`](crate::exchange::MailLabel)). Mail, /// A remote mailbox's routing address (`targetAddress`). Routing,