Skip to content

Commit af8f2fc

Browse files
authored
Merge pull request #332 from AdaWorldAPI/ccr-0455e606-wmtsor
ogar-dir-sim: Mail is a label that hydrates the Exchange identity (recipient types + ExchangeGuid + PrimarySmtpAddress)
2 parents 2fbae6e + 89991e2 commit af8f2fc

5 files changed

Lines changed: 121 additions & 10 deletions

File tree

‎crates/ogar-ad/src/lib.rs‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use ogar_dir_core::{
2525
};
2626

2727
/// Encoder schema version understood by this crate.
28-
pub const SCHEMA_VERSION: u16 = 5;
28+
pub const SCHEMA_VERSION: u16 = 6;
2929
/// This crate's schema id.
3030
pub const SCHEMA: SchemaId = SchemaId {
3131
family: SchemaFamily::AdDs,
@@ -64,6 +64,12 @@ pub const SCHEMA: SchemaId = SchemaId {
6464
/// v5 adds `extensionAttribute1`..`15` as one positional bag
6565
/// ([`AttrKind::Bag`]), the shape Graph returns them in
6666
/// (`onPremisesExtensionAttributes`).
67+
///
68+
/// v6 adds `msExchMailboxGuid`, the mailbox's own GUID (16 mixed-endian
69+
/// bytes, like `objectGUID`), in an inline guid slot. Exchange Online keeps
70+
/// it as the mailbox's `ExchangeGuid` across a move, so it is the mailbox
71+
/// half of an object's Exchange identity (`ogar-dir-sim::ExchangeIdentity`)
72+
/// and, like the source anchor, never text.
6773
pub const SCHEMA_V1: &[AttrDef] = &[
6874
AttrDef {
6975
name: "distinguishedName",
@@ -197,6 +203,12 @@ pub const SCHEMA_V1: &[AttrDef] = &[
197203
kind: AttrKind::Bag,
198204
since: 5,
199205
},
206+
AttrDef {
207+
name: "msExchMailboxGuid",
208+
slot: 2,
209+
kind: AttrKind::Guid,
210+
since: 6,
211+
},
200212
];
201213

202214
/// AD object kinds (family-scoped codes in `object_kind`).
@@ -319,7 +331,7 @@ pub fn cloud_label(kind: AdKind) -> Option<CloudLabel> {
319331
/// Inbound: a 128-bit id attribute's source spelling → the id.
320332
fn decode_id(name: &str, raw: &[u8], kind: u16) -> Option<Guid128> {
321333
let g = match name {
322-
"mS-DS-ConsistencyGuid" => Guid128::from_ms_bytes(raw).ok()?,
334+
"mS-DS-ConsistencyGuid" | "msExchMailboxGuid" => Guid128::from_ms_bytes(raw).ok()?,
323335
"msDS-ExternalDirectoryObjectId" => {
324336
// The kind's own template: a User_ id on a group does not match.
325337
let label = cloud_label(AdKind::from_code(kind)?)?;
@@ -351,6 +363,11 @@ pub fn consistency_guid(rec: &DirRecord) -> Option<[u8; 16]> {
351363
Some(rec.guid(guid_slot("mS-DS-ConsistencyGuid"))?.to_ms_bytes())
352364
}
353365

366+
/// Outbound: `msExchMailboxGuid` as AD stores it (16 mixed-endian bytes).
367+
pub fn exchange_guid(rec: &DirRecord) -> Option<[u8; 16]> {
368+
Some(rec.guid(guid_slot("msExchMailboxGuid"))?.to_ms_bytes())
369+
}
370+
354371
/// `base` is a member of a bag attribute (`extensionAttribute7`).
355372
fn is_bag_member(base: &str) -> bool {
356373
SCHEMA_V1

‎crates/ogar-ad/tests/main.rs‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,3 +382,37 @@ fn extension_attributes_are_one_bag() {
382382
assert_eq!(odd.record.str_ref(slot("extensionAttribute")), None);
383383
assert_eq!(odd.ignored, vec!["extensionAttribute01"]);
384384
}
385+
386+
// msExchMailboxGuid is the mailbox's own GUID: stored as a 128-bit id in a
387+
// guid slot (never pooled text) and given back as AD's 16 mixed-endian
388+
// bytes. A value that is not 16 bytes is refused, never stored as text.
389+
#[test]
390+
fn the_exchange_guid_is_an_id_not_a_string() {
391+
const BOX: &str = "Ab5xLqKmTkiZ8gH0cDeFqw==";
392+
let text = format!(
393+
"dn: CN=X,OU=Staff,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: user\nmsExchMailboxGuid:: {BOX}\n"
394+
);
395+
let e = &ldif::parse(&text).unwrap()[0];
396+
let (mut dict, mut pool) = (OuDictionary::new(), ValuePool::new());
397+
let enc = encode(e, domain(), &mut dict, &mut pool, 1).unwrap();
398+
assert!(enc.ignored.is_empty(), "{:?}", enc.ignored);
399+
let r = enc.record;
400+
let raw = ogar_dir_core::base64::decode(BOX).unwrap();
401+
assert_eq!(
402+
r.guid(slot("msExchMailboxGuid")),
403+
Some(Guid128::from_ms_bytes(&raw).unwrap())
404+
);
405+
assert_ne!(r.guid(slot("msExchMailboxGuid")), Some(r.node_guid()));
406+
for s in 0..32 {
407+
if let Some(sr) = r.str_ref(s) {
408+
let v = pool.get(sr).unwrap();
409+
assert!(!v.windows(raw.len()).any(|w| w == raw.as_slice()));
410+
}
411+
}
412+
assert_eq!(ogar_ad::exchange_guid(&r).map(|b| b.to_vec()), Some(raw));
413+
414+
let bad = "dn: CN=X,OU=Staff,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: user\nmsExchMailboxGuid:: AAEC\n";
415+
let e = &ldif::parse(bad).unwrap()[0];
416+
let (mut dict, mut pool) = (OuDictionary::new(), ValuePool::new());
417+
assert!(encode(e, domain(), &mut dict, &mut pool, 1).is_err());
418+
}

‎crates/ogar-dir-sim/src/exchange.rs‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
//! **The enabled flag is not part of this.** A shared, room or equipment
2424
//! mailbox is a disabled account by design and is still a recipient.
2525
26-
use crate::change::ValueId;
26+
use crate::change::{KeyId, ValueId};
27+
use ogar_dir_core::Guid128;
2728

2829
/// `msExchRemoteRecipientType` flag bits.
2930
pub mod flag {
@@ -334,6 +335,59 @@ impl Recipient {
334335
}
335336
}
336337

338+
/// What Exchange knows an object as, read by its GUID: its recipient types
339+
/// ([`Recipient`]), its mailbox's `ExchangeGuid` (`msExchMailboxGuid`
340+
/// on-premises; Exchange Online keeps the same value as the mailbox's
341+
/// `ExchangeGuid` across a move) and the address it receives at
342+
/// (`PrimarySmtpAddress`).
343+
///
344+
/// The identity is `node` and, for a mailbox, `exchange_guid`; the address is
345+
/// a value of the identity, never the key it is found by.
346+
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
347+
pub struct ExchangeIdentity {
348+
/// The object (`objectGUID`).
349+
pub node: Guid128,
350+
/// Recipient types; `None` = the source did not read them.
351+
pub recipient: Option<Recipient>,
352+
/// `msExchMailboxGuid`; `None` = no mailbox, or not read.
353+
pub exchange_guid: Option<Guid128>,
354+
/// `PrimarySmtpAddress`.
355+
pub primary_smtp: Option<ValueId>,
356+
}
357+
358+
/// What an object's `mail` label resolves to. The label is the trigger: its
359+
/// address is looked up once, among the addresses objects actually hold
360+
/// (UPN, `proxyAddresses`, routing address), and the holder's
361+
/// [`ExchangeIdentity`] is hydrated by GUID. The label itself never reserves
362+
/// the address and never decides delivery or access.
363+
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
364+
pub enum MailLabel {
365+
/// The label is an address the labelled object holds itself.
366+
Own(ExchangeIdentity),
367+
/// The label names an address another object holds: an admin account
368+
/// whose `mail` is a user's mailbox (a password-reset target), say. The
369+
/// identity is the holder's.
370+
Elsewhere {
371+
/// The label's comparison key.
372+
key: KeyId,
373+
/// The object that holds the address.
374+
holder: ExchangeIdentity,
375+
},
376+
/// Several objects hold the address: none is hydrated, the holders are
377+
/// listed (sorted).
378+
Contested {
379+
/// The label's comparison key.
380+
key: KeyId,
381+
/// Every holder.
382+
holders: Vec<Guid128>,
383+
},
384+
/// No object holds the address: a stale label.
385+
Unheld {
386+
/// The label's comparison key.
387+
key: KeyId,
388+
},
389+
}
390+
337391
/// One step of the hybrid remote-mailbox lifecycle (semantic; the actuator
338392
/// maps it to its own commands).
339393
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]

‎crates/ogar-dir-sim/src/lib.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ pub use change::{
2626
Attribute, Change, KeyId, NodeKind, NodeState, Refusal, ValueId, effective_active, normalize,
2727
};
2828
pub use exchange::{
29-
AddressTemplate, ArchiveState, LifecycleRefusal, MailboxState, ROUTING, Recipient,
30-
RecipientAttributes, RemoteKind, RemoteMailbox, RemoteMailboxOp, routing_parts,
29+
AddressTemplate, ArchiveState, ExchangeIdentity, LifecycleRefusal, MailLabel, MailboxState,
30+
ROUTING, Recipient, RecipientAttributes, RemoteKind, RemoteMailbox, RemoteMailboxOp,
31+
routing_parts,
3132
};
3233
pub use identity::{CloudLabel, ExternalObjectId, SourceAnchor};
3334
pub use plan::{ExecutionPlan, Operation, PlanError, PlannedOp, Precondition};

‎crates/ogar-dir-sim/src/violation.rs‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ pub enum AddressRole {
2525
PrimarySmtp,
2626
/// A secondary SMTP proxy (`smtp:`).
2727
SecondarySmtp,
28-
/// The `mail` attribute.
28+
/// The `mail` attribute: a label, never a claim. Exchange does not
29+
/// reserve it, so it never makes an object a holder in
30+
/// [`Violation::AddressConflict`]. A label is a hydration trigger: it is
31+
/// resolved to the object that does hold the address it names, and that
32+
/// object's [`ExchangeIdentity`](crate::exchange::ExchangeIdentity) is
33+
/// read by GUID ([`MailLabel`](crate::exchange::MailLabel)).
2934
Mail,
3035
/// A remote mailbox's routing address (`targetAddress`).
3136
Routing,
@@ -49,10 +54,10 @@ pub enum Violation {
4954
owners: Vec<Guid128>,
5055
},
5156
/// Two or more objects hold the same normalized address under different
52-
/// attributes: a UPN or SMTP address that is another object's SMTP,
53-
/// `mail` or routing address, or a `mail` held by another object (an
54-
/// admin account whose `mail` points at someone's mailbox as a
55-
/// password-reset target, for example). Same-attribute collisions are
57+
/// attributes: a UPN or SMTP address that is another object's SMTP or
58+
/// routing address. A `mail` value is not a claim (an admin account
59+
/// whose `mail` points at someone's mailbox as a password-reset target
60+
/// holds nothing), so it never appears here. Same-attribute collisions are
5661
/// [`Violation::DuplicateSmtp`] / [`Violation::DuplicateUpn`]; one object
5762
/// holding an address under several attributes is not a conflict.
5863
AddressConflict {

0 commit comments

Comments
 (0)