Skip to main content

adhammer_bloodhound/
lib.rs

1//! BloodHound export — turn a collected [`Snapshot`] into BloodHound CE ingest JSON that the
2//! BloodHound UI ingests, so the in-process control-path graph becomes explorable in the tool
3//! every AD team already uses. Targets the BloodHound Community Edition ingest format: one file per
4//! node type (`users`/`computers`/`groups`/`domains`/`ous`/`gpos`/`containers`), each a
5//! `{"data":[…],"meta":{…}}` document, packaged into a single `.zip`.
6//!
7//! The `meta.version` field in this crate's built-in exporter is emitted as `5` (the
8//! historical BloodHound-CE ingest schema this crate first targeted). The opt-in
9//! [`rusthound_ce`] adapter (feature `rusthound-ce`) delegates to RustHound-CE upstream,
10//! which currently emits `meta.version = 6` and additional ADCS-extension file types
11//! (`aiacas`/`enterprisecas`/`rootcas`/`certtemplates`/`issuancepolicies`/`ntauthstores`).
12//! Both are accepted by current BloodHound-CE releases.
13//!
14//! Node identity: SIDs for security principals, GUIDs for OUs/GPOs/containers. Edges come from
15//! group membership (`Members`) and from ACEs parsed out of `nTSecurityDescriptor` — the same
16//! rights the control-path graph walks, mapped to BloodHound `RightName`s.
17
18use adhammer_core::sid::{Guid, Sid};
19use adhammer_core::snapshot::Snapshot;
20use adhammer_core::AdObject;
21use serde_json::{json, Value};
22use std::collections::HashMap;
23use windows_sddl::rights;
24use windows_sddl::{AccessMask, AceType};
25
26// Opt-in 1.5.2 RustHound-CE adapter (feature-gated, additive, not default).
27#[cfg(feature = "rusthound-ce")]
28pub mod rusthound_ce;
29
30const VERSION: u32 = 5;
31
32/// The object types BloodHound distinguishes, as they appear in JSON.
33#[derive(Clone, Copy, PartialEq)]
34enum Kind {
35    User,
36    Computer,
37    Group,
38    Domain,
39    Ou,
40    Gpo,
41    Container,
42    Base,
43}
44
45impl Kind {
46    fn as_str(self) -> &'static str {
47        match self {
48            Kind::User => "User",
49            Kind::Computer => "Computer",
50            Kind::Group => "Group",
51            Kind::Domain => "Domain",
52            Kind::Ou => "OU",
53            Kind::Gpo => "GPO",
54            Kind::Container => "Container",
55            Kind::Base => "Base",
56        }
57    }
58}
59
60fn kind_of(o: &AdObject) -> Kind {
61    if o.has_class("computer") {
62        Kind::Computer
63    } else if o.has_class("group") {
64        Kind::Group
65    } else if o.has_class("domainDNS") {
66        Kind::Domain
67    } else if o.has_class("groupPolicyContainer") {
68        Kind::Gpo
69    } else if o.has_class("organizationalUnit") {
70        Kind::Ou
71    } else if o.has_class("user") {
72        Kind::User
73    } else if o.has_class("container") {
74        Kind::Container
75    } else {
76        Kind::Base
77    }
78}
79
80/// DC=corp,DC=local → "corp.local".
81fn dn_to_fqdn(dn: &str) -> String {
82    dn.split(',')
83        .filter_map(|p| p.trim().strip_prefix("DC="))
84        .collect::<Vec<_>>()
85        .join(".")
86}
87
88fn guid_upper(o: &AdObject) -> Option<String> {
89    o.bin1("objectGUID")
90        .and_then(Guid::from_bytes)
91        .map(|g| g.to_string().to_uppercase())
92}
93
94/// The BloodHound identity for an object: SID (principals) or GUID (OU/GPO/container).
95fn object_id(o: &AdObject, kind: Kind) -> Option<String> {
96    match kind {
97        Kind::User | Kind::Computer | Kind::Group | Kind::Domain => o
98            .bin1("objectSid")
99            .and_then(Sid::from_bytes)
100            .map(|s| s.to_string().to_uppercase()),
101        _ => guid_upper(o),
102    }
103}
104
105struct Ctx {
106    domain_fqdn_upper: String,
107    domain_sid: String,
108    /// SID → node type, for resolving ACE trustees and group members.
109    type_by_sid: HashMap<String, Kind>,
110    /// member DN → (id, type), for group `Members` and containment.
111    id_by_dn: HashMap<String, (String, Kind)>,
112}
113
114/// Build the full set of BloodHound JSON files (filename, bytes) for a snapshot.
115pub fn export_files(snap: &Snapshot) -> Vec<(String, Vec<u8>)> {
116    let fqdn = dn_to_fqdn(&snap.domain.domain_dn);
117    let domain_sid = snap
118        .domain
119        .domain_sid
120        .as_ref()
121        .map(|s| s.to_string().to_uppercase())
122        .unwrap_or_default();
123
124    let mut ctx = Ctx {
125        domain_fqdn_upper: fqdn.to_uppercase(),
126        domain_sid,
127        type_by_sid: HashMap::new(),
128        id_by_dn: HashMap::new(),
129    };
130    for o in &snap.objects {
131        let k = kind_of(o);
132        if let Some(sid) = o.bin1("objectSid").and_then(Sid::from_bytes) {
133            ctx.type_by_sid.insert(sid.to_string().to_uppercase(), k);
134        }
135        if let Some(id) = object_id(o, k) {
136            ctx.id_by_dn.insert(o.dn.to_uppercase(), (id, k));
137        }
138    }
139
140    let mut buckets: HashMap<&'static str, Vec<Value>> = HashMap::new();
141    for name in [
142        "users",
143        "computers",
144        "groups",
145        "domains",
146        "ous",
147        "gpos",
148        "containers",
149    ] {
150        buckets.insert(name, Vec::new());
151    }
152
153    for o in &snap.objects {
154        let kind = kind_of(o);
155        let Some(id) = object_id(o, kind) else {
156            continue;
157        };
158        let node = build_node(&ctx, o, kind, &id);
159        let bucket = match kind {
160            Kind::User => "users",
161            Kind::Computer => "computers",
162            Kind::Group => "groups",
163            Kind::Domain => "domains",
164            Kind::Ou => "ous",
165            Kind::Gpo => "gpos",
166            Kind::Container | Kind::Base => "containers",
167        };
168        buckets.get_mut(bucket).unwrap().push(node);
169    }
170
171    buckets
172        .into_iter()
173        .map(|(typ, data)| {
174            let doc = json!({
175                "data": data,
176                "meta": { "methods": 0, "type": typ, "count": data.len(), "version": VERSION }
177            });
178            (
179                format!("{}_{typ}.json", ctx.domain_fqdn_upper.to_lowercase()),
180                serde_json::to_vec(&doc).unwrap(),
181            )
182        })
183        .collect()
184}
185
186/// Export a snapshot to a single BloodHound `.zip` at `path`.
187pub fn export_zip(snap: &Snapshot, path: &std::path::Path) -> std::io::Result<usize> {
188    let files = export_files(snap);
189    let n = files.len();
190    let zip = zip_store(&files);
191    std::fs::write(path, zip)?;
192    Ok(n)
193}
194
195fn build_node(ctx: &Ctx, o: &AdObject, kind: Kind, id: &str) -> Value {
196    let mut props = base_properties(ctx, o, kind);
197    let aces = build_aces(ctx, o);
198    let owner_ace = owner_ace(ctx, o);
199
200    let mut node = json!({
201        "ObjectIdentifier": id,
202        "Aces": [],
203        "IsDeleted": false,
204        "IsACLProtected": false,
205    });
206    // Owner first (BloodHound convention), then the DACL-derived ACEs.
207    let mut ace_list = Vec::new();
208    ace_list.extend(owner_ace);
209    ace_list.extend(aces);
210    node["Aces"] = Value::Array(ace_list);
211
212    match kind {
213        Kind::User => {
214            props["hasspn"] = json!(!o.all("servicePrincipalName").is_empty());
215            node["Properties"] = props;
216            node["PrimaryGroupSID"] = json!(primary_group_sid(ctx, o));
217            node["HasSIDHistory"] = json!(sid_history(o));
218            node["SPNTargets"] = json!([]);
219            node["AllowedToDelegate"] = json!([]);
220        }
221        Kind::Computer => {
222            node["Properties"] = props;
223            node["PrimaryGroupSID"] = json!(primary_group_sid(ctx, o));
224            node["HasSIDHistory"] = json!(sid_history(o));
225            node["AllowedToDelegate"] = json!([]);
226            node["AllowedToAct"] = json!([]);
227            node["DumpSMSAPassword"] = json!([]);
228            for coll in ["Sessions", "PrivilegedSessions", "RegistrySessions"] {
229                node[coll] = json!({"Results": [], "Collected": false, "FailureReason": null});
230            }
231            for coll in [
232                "LocalAdmins",
233                "RemoteDesktopUsers",
234                "DcomUsers",
235                "PSRemoteUsers",
236            ] {
237                node[coll] = json!({"Results": [], "Collected": false, "FailureReason": null});
238            }
239            node["Status"] = Value::Null;
240        }
241        Kind::Group => {
242            node["Properties"] = props;
243            node["Members"] = json!(group_members(ctx, o));
244        }
245        Kind::Domain => {
246            node["Properties"] = props;
247            node["Trusts"] = json!([]);
248            node["Links"] = json!([]);
249            node["ChildObjects"] = json!([]);
250            node["GPOChanges"] = json!({
251                "LocalAdmins": [], "RemoteDesktopUsers": [], "DcomUsers": [],
252                "PSRemoteUsers": [], "AffectedComputers": []
253            });
254        }
255        Kind::Ou => {
256            node["Properties"] = props;
257            node["Links"] = json!([]);
258            node["ChildObjects"] = json!([]);
259            node["GPOChanges"] = json!({
260                "LocalAdmins": [], "RemoteDesktopUsers": [], "DcomUsers": [],
261                "PSRemoteUsers": [], "AffectedComputers": []
262            });
263        }
264        Kind::Gpo => {
265            node["Properties"] = props;
266        }
267        Kind::Container | Kind::Base => {
268            node["Properties"] = props;
269            node["ChildObjects"] = json!([]);
270        }
271    }
272    node
273}
274
275fn base_properties(ctx: &Ctx, o: &AdObject, kind: Kind) -> Value {
276    let dn = o.dn.to_uppercase();
277    let name = display_name(ctx, o, kind);
278    let mut p = json!({
279        "domain": ctx.domain_fqdn_upper,
280        "domainsid": ctx.domain_sid,
281        "name": name,
282        "distinguishedname": dn,
283        "whencreated": o.int("whenCreated").unwrap_or(0),
284    });
285    if let Some(desc) = o.one("description") {
286        p["description"] = json!(desc);
287    }
288    let admincount = o.int("adminCount").unwrap_or(0) != 0;
289    p["admincount"] = json!(admincount);
290    p["highvalue"] = json!(is_high_value(ctx, o, kind));
291
292    if matches!(kind, Kind::User | Kind::Computer) {
293        let uac = o.uac();
294        p["enabled"] = json!(uac & adhammer_core::object::uac::ACCOUNTDISABLE == 0);
295        p["unconstraineddelegation"] =
296            json!(uac & adhammer_core::object::uac::TRUSTED_FOR_DELEGATION != 0);
297        p["trustedtoauth"] =
298            json!(uac & adhammer_core::object::uac::TRUSTED_TO_AUTH_FOR_DELEGATION != 0);
299        p["pwdlastset"] = json!(filetime_to_unix(o.filetime("pwdLastSet")));
300        p["lastlogontimestamp"] = json!(filetime_to_unix(o.filetime("lastLogonTimestamp")));
301        p["samaccountname"] = json!(o.one("sAMAccountName").unwrap_or_default());
302        if let Some(spns) = o.attrs.get("servicePrincipalName") {
303            p["serviceprincipalnames"] = json!(spns);
304        }
305    }
306    if kind == Kind::User {
307        let uac = o.uac();
308        p["dontreqpreauth"] = json!(uac & adhammer_core::object::uac::DONT_REQ_PREAUTH != 0);
309        p["passwordnotreqd"] = json!(uac & adhammer_core::object::uac::PASSWD_NOTREQD != 0);
310        p["sensitive"] = json!(uac & adhammer_core::object::uac::NOT_DELEGATED != 0);
311        if let Some(upn) = o.one("userPrincipalName") {
312            p["email"] = json!(upn);
313        }
314    }
315    if kind == Kind::Computer {
316        p["operatingsystem"] = json!(o.one("operatingSystem").unwrap_or_default());
317    }
318    p
319}
320
321/// BloodHound `name`: PRINCIPAL@DOMAIN for users/groups, HOST.DOMAIN for computers, the FQDN
322/// for the domain itself, otherwise the object's CN@DOMAIN.
323fn display_name(ctx: &Ctx, o: &AdObject, kind: Kind) -> String {
324    let dom = &ctx.domain_fqdn_upper;
325    match kind {
326        Kind::Domain => dom.clone(),
327        Kind::Computer => {
328            let host = o
329                .one("dNSHostName")
330                .map(str::to_string)
331                .or_else(|| {
332                    o.one("sAMAccountName")
333                        .map(|s| s.trim_end_matches('$').to_string())
334                })
335                .unwrap_or_default()
336                .to_uppercase();
337            if host.contains('.') {
338                host
339            } else {
340                format!("{host}.{dom}")
341            }
342        }
343        _ => {
344            let sam = o
345                .one("sAMAccountName")
346                .map(str::to_string)
347                .or_else(|| o.one("cn").map(str::to_string))
348                .or_else(|| o.one("name").map(str::to_string))
349                .unwrap_or_default()
350                .to_uppercase();
351            format!("{sam}@{dom}")
352        }
353    }
354}
355
356fn is_high_value(ctx: &Ctx, o: &AdObject, kind: Kind) -> bool {
357    // Well-known Tier-0 RIDs / groups (Domain Admins 512, Enterprise Admins 519, etc.).
358    if kind == Kind::Group {
359        if let Some(sid) = o.bin1("objectSid").and_then(Sid::from_bytes) {
360            if let Some(rid) = sid.rid() {
361                if matches!(rid, 512 | 516 | 518 | 519 | 520)
362                    && sid.to_string().to_uppercase().starts_with(&ctx.domain_sid)
363                {
364                    return true;
365                }
366            }
367            // BUILTIN\Administrators S-1-5-32-544
368            if sid.to_string() == "S-1-5-32-544" {
369                return true;
370            }
371        }
372    }
373    false
374}
375
376fn primary_group_sid(ctx: &Ctx, o: &AdObject) -> Option<String> {
377    o.int("primaryGroupID")
378        .map(|rid| format!("{}-{}", ctx.domain_sid, rid))
379}
380
381fn sid_history(o: &AdObject) -> Vec<Value> {
382    o.bin_all("sIDHistory")
383        .iter()
384        .filter_map(|b| Sid::from_bytes(b))
385        .map(|s| {
386            let id = s.to_string().to_uppercase();
387            json!({"ObjectIdentifier": id, "ObjectType": "User"})
388        })
389        .collect()
390}
391
392fn group_members(ctx: &Ctx, o: &AdObject) -> Vec<Value> {
393    o.all("member")
394        .iter()
395        .filter_map(|dn| ctx.id_by_dn.get(&dn.to_uppercase()))
396        .map(|(id, k)| json!({"ObjectIdentifier": id, "ObjectType": k.as_str()}))
397        .collect()
398}
399
400fn principal_type(ctx: &Ctx, sid: &Sid) -> &'static str {
401    ctx.type_by_sid
402        .get(&sid.to_string().to_uppercase())
403        .copied()
404        .unwrap_or(Kind::Base)
405        .as_str()
406}
407
408/// The object owner, as an `Owns` ACE (BloodHound models ownership as an edge).
409fn owner_ace(ctx: &Ctx, o: &AdObject) -> Vec<Value> {
410    let Some(raw) = o.bin1("nTSecurityDescriptor") else {
411        return vec![];
412    };
413    let Ok(sd) = windows_sddl::parse(raw) else {
414        return vec![];
415    };
416    let Some(owner) = sd.owner else { return vec![] };
417    if owner.is_well_known() {
418        return vec![];
419    }
420    vec![ace(
421        &owner.to_string().to_uppercase(),
422        principal_type(ctx, &owner),
423        "Owns",
424        false,
425    )]
426}
427
428/// Parse the DACL into BloodHound ACEs (allow-only, non-well-known trustees).
429fn build_aces(ctx: &Ctx, o: &AdObject) -> Vec<Value> {
430    let Some(raw) = o.bin1("nTSecurityDescriptor") else {
431        return vec![];
432    };
433    let Ok(sd) = windows_sddl::parse(raw) else {
434        return vec![];
435    };
436    if sd.dacl_kind != windows_sddl::DaclKind::Present {
437        return vec![ace("S-1-1-0", "Group", "GenericAll", false)];
438    }
439    let mut out = Vec::new();
440    for a in sd.dacl.iter().flat_map(|d| &d.aces) {
441        if a.ace_type == AceType::AccessDenied || a.ace_type == AceType::AccessDeniedObject {
442            continue;
443        }
444        if a.trustee.is_well_known() {
445            continue;
446        }
447        let inherited = a.flags & 0x10 != 0; // INHERITED_ACE
448        let sid = a.trustee.to_string().to_uppercase();
449        let ptype = principal_type(ctx, &a.trustee);
450        for right in ace_rights(a) {
451            out.push(ace(&sid, ptype, right, inherited));
452        }
453    }
454    out
455}
456
457fn ace(principal: &str, ptype: &str, right: &str, inherited: bool) -> Value {
458    json!({
459        "PrincipalSID": principal,
460        "PrincipalType": ptype,
461        "RightName": right,
462        "IsInherited": inherited,
463    })
464}
465
466/// Map an ACE's access mask + object-type GUID to BloodHound `RightName`s.
467fn ace_rights(a: &windows_sddl::Ace) -> Vec<&'static str> {
468    let m = a.mask;
469    let mut v = Vec::new();
470    if m.contains(AccessMask::GENERIC_ALL) {
471        return vec!["GenericAll"];
472    }
473    if m.contains(AccessMask::WRITE_DAC) {
474        v.push("WriteDacl");
475    }
476    if m.contains(AccessMask::WRITE_OWNER) {
477        v.push("WriteOwner");
478    }
479    if m.contains(AccessMask::GENERIC_WRITE) {
480        v.push("GenericWrite");
481    }
482    if m.contains(AccessMask::CONTROL_ACCESS) {
483        match &a.object_type {
484            None => v.push("AllExtendedRights"),
485            Some(g) if rights::FORCE_CHANGE_PASSWORD.matches(g) => v.push("ForceChangePassword"),
486            Some(g) if rights::REPL_GET_CHANGES.matches(g) => v.push("GetChanges"),
487            Some(g) if rights::REPL_GET_CHANGES_ALL.matches(g) => v.push("GetChangesAll"),
488            Some(_) => {}
489        }
490    }
491    if m.contains(AccessMask::WRITE_PROP) {
492        match &a.object_type {
493            None => {
494                if !v.contains(&"GenericWrite") {
495                    v.push("GenericWrite");
496                }
497            }
498            Some(g) if rights::MEMBER_ATTR.matches(g) => v.push("AddMember"),
499            Some(g) if rights::KEY_CREDENTIAL_LINK.matches(g) => v.push("AddKeyCredentialLink"),
500            Some(g) if rights::RBCD_ATTR.matches(g) => v.push("AddAllowedToAct"),
501            Some(_) => {}
502        }
503    }
504    v
505}
506
507/// Windows FILETIME (100 ns ticks since 1601) → Unix seconds; -1 for never/absent.
508fn filetime_to_unix(ft: Option<i64>) -> i64 {
509    match ft {
510        Some(t) if t > 0 => t / 10_000_000 - 11_644_473_600,
511        _ => -1,
512    }
513}
514
515// ---- Minimal STORED (uncompressed) ZIP writer -----------------------------------------------
516// BloodHound accepts stored zips; a from-scratch writer keeps the dependency footprint at zero.
517
518fn zip_store(files: &[(String, Vec<u8>)]) -> Vec<u8> {
519    let mut out = Vec::new();
520    let mut central = Vec::new();
521    let mut offsets = Vec::new();
522
523    for (name, data) in files {
524        let crc = crc32(data);
525        let off = out.len() as u32;
526        offsets.push(off);
527        let name_b = name.as_bytes();
528        // Local file header.
529        out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
530        out.extend_from_slice(&20u16.to_le_bytes()); // version needed
531        out.extend_from_slice(&0u16.to_le_bytes()); // flags
532        out.extend_from_slice(&0u16.to_le_bytes()); // method = stored
533        out.extend_from_slice(&0u16.to_le_bytes()); // mod time
534        out.extend_from_slice(&0u16.to_le_bytes()); // mod date
535        out.extend_from_slice(&crc.to_le_bytes());
536        out.extend_from_slice(&(data.len() as u32).to_le_bytes()); // compressed size
537        out.extend_from_slice(&(data.len() as u32).to_le_bytes()); // uncompressed size
538        out.extend_from_slice(&(name_b.len() as u16).to_le_bytes());
539        out.extend_from_slice(&0u16.to_le_bytes()); // extra len
540        out.extend_from_slice(name_b);
541        out.extend_from_slice(data);
542    }
543
544    for ((name, data), off) in files.iter().zip(offsets) {
545        let crc = crc32(data);
546        let name_b = name.as_bytes();
547        central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
548        central.extend_from_slice(&20u16.to_le_bytes()); // version made by
549        central.extend_from_slice(&20u16.to_le_bytes()); // version needed
550        central.extend_from_slice(&0u16.to_le_bytes()); // flags
551        central.extend_from_slice(&0u16.to_le_bytes()); // method
552        central.extend_from_slice(&0u16.to_le_bytes()); // time
553        central.extend_from_slice(&0u16.to_le_bytes()); // date
554        central.extend_from_slice(&crc.to_le_bytes());
555        central.extend_from_slice(&(data.len() as u32).to_le_bytes());
556        central.extend_from_slice(&(data.len() as u32).to_le_bytes());
557        central.extend_from_slice(&(name_b.len() as u16).to_le_bytes());
558        central.extend_from_slice(&0u16.to_le_bytes()); // extra len
559        central.extend_from_slice(&0u16.to_le_bytes()); // comment len
560        central.extend_from_slice(&0u16.to_le_bytes()); // disk number
561        central.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
562        central.extend_from_slice(&0u32.to_le_bytes()); // external attrs
563        central.extend_from_slice(&off.to_le_bytes());
564        central.extend_from_slice(name_b);
565    }
566
567    let central_off = out.len() as u32;
568    let central_size = central.len() as u32;
569    out.extend_from_slice(&central);
570    // End of central directory.
571    out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
572    out.extend_from_slice(&0u16.to_le_bytes()); // this disk
573    out.extend_from_slice(&0u16.to_le_bytes()); // cd start disk
574    out.extend_from_slice(&(files.len() as u16).to_le_bytes());
575    out.extend_from_slice(&(files.len() as u16).to_le_bytes());
576    out.extend_from_slice(&central_size.to_le_bytes());
577    out.extend_from_slice(&central_off.to_le_bytes());
578    out.extend_from_slice(&0u16.to_le_bytes()); // comment len
579    out
580}
581
582fn crc32(data: &[u8]) -> u32 {
583    let mut crc = 0xFFFF_FFFFu32;
584    for &b in data {
585        crc ^= b as u32;
586        for _ in 0..8 {
587            let mask = (crc & 1).wrapping_neg();
588            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
589        }
590    }
591    !crc
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn crc32_matches_known_vector() {
600        // CRC-32 of "123456789" is 0xCBF43926.
601        assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
602    }
603
604    #[test]
605    fn zip_has_signatures() {
606        let z = zip_store(&[("a.json".into(), b"{}".to_vec())]);
607        assert_eq!(&z[0..4], &0x0403_4b50u32.to_le_bytes()); // local header
608                                                             // EOCD signature appears near the end.
609        assert!(z.windows(4).any(|w| w == 0x0605_4b50u32.to_le_bytes()));
610    }
611
612    #[test]
613    fn fqdn_from_dn() {
614        assert_eq!(dn_to_fqdn("DC=corp,DC=local"), "corp.local");
615    }
616
617    #[test]
618    fn filetime_conversion() {
619        assert_eq!(filetime_to_unix(None), -1);
620        assert_eq!(filetime_to_unix(Some(0)), -1);
621        // 2021-01-01T00:00:00Z = 1609459200 unix = (1609459200 + 11644473600) * 1e7 filetime.
622        assert_eq!(
623            filetime_to_unix(Some(132_539_328_000_000_000)),
624            1_609_459_200
625        );
626    }
627}