diff --git a/src/ipinfo.zig b/src/ipinfo.zig index 6d21422..82024d6 100644 --- a/src/ipinfo.zig +++ b/src/ipinfo.zig @@ -302,7 +302,7 @@ fn parseCymruAsnOrg(allocator: Allocator, txt: []const u8) ?[]const u8 { // --- DNS TXT query via raw UDP --- -fn queryTxt(allocator: Allocator, name: []const u8) ![]const u8 { +pub fn queryTxt(allocator: Allocator, name: []const u8) ![]const u8 { const ns_ip = getNameserver(allocator) catch try allocator.dupe(u8, "8.8.8.8"); defer allocator.free(ns_ip); diff --git a/src/lib.zig b/src/lib.zig index cfc9450..3524e9e 100644 --- a/src/lib.zig +++ b/src/lib.zig @@ -151,38 +151,6 @@ export fn reports_fetch_account(config_json: [*:0]const u8, account_name: [*:0]c return errStr("Account not found"); } -/// Escape a string for embedding in JSON. Returns the original slice if no escaping needed. -fn jsonEscapeAlloc(input: []const u8) ?[]const u8 { - var needs_escape = false; - for (input) |ch| { - if (ch == '"' or ch == '\\' or ch < 0x20) { - needs_escape = true; - break; - } - } - if (!needs_escape) return input; - - var buf: std.ArrayList(u8) = .empty; - for (input) |ch| { - switch (ch) { - '"' => buf.appendSlice(allocator, "\\\"") catch return null, - '\\' => buf.appendSlice(allocator, "\\\\") catch return null, - '\n' => buf.appendSlice(allocator, "\\n") catch return null, - '\r' => buf.appendSlice(allocator, "\\r") catch return null, - '\t' => buf.appendSlice(allocator, "\\t") catch return null, - else => if (ch < 0x20) { - buf.appendSlice(allocator, "\\u00") catch return null; - const hex = "0123456789abcdef"; - buf.append(allocator, hex[ch >> 4]) catch return null; - buf.append(allocator, hex[ch & 0x0f]) catch return null; - } else { - buf.append(allocator, ch) catch return null; - }, - } - } - return buf.toOwnedSlice(allocator) catch null; -} - fn errStr(msg: []const u8) ?[*:0]u8 { const duped = allocator.dupeZ(u8, msg) catch return null; return duped.ptr; @@ -737,9 +705,9 @@ fn buildSourcesJson(data_dir: []const u8, entries: []const reports.store.ReportE } // JSON-escape enrichment strings that may contain quotes/backslashes - const esc_ptr = jsonEscapeAlloc(ptr_str) orelse ptr_str; + const esc_ptr = reports.stats.jsonEscape(allocator, ptr_str); defer if (esc_ptr.ptr != ptr_str.ptr) allocator.free(esc_ptr); - const esc_asn_org = jsonEscapeAlloc(asn_org_str) orelse asn_org_str; + const esc_asn_org = reports.stats.jsonEscape(allocator, asn_org_str); defer if (esc_asn_org.ptr != asn_org_str.ptr) allocator.free(esc_asn_org); const line = std.fmt.allocPrint(allocator, "{{\"ip\":\"{s}\",\"messages\":{d},\"dmarc_issues\":{d},\"tls_failures\":{d},\"types\":{s},\"domains\":{s},\"ptr\":\"{s}\",\"asn\":\"{s}\",\"asn_org\":\"{s}\",\"country\":\"{s}\"}}", .{ diff --git a/src/main.zig b/src/main.zig index 67e15e3..c75cc6d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -56,6 +56,8 @@ pub fn main() !void { return; } try cmdShow(allocator, args[2], format orelse "table", enrich); + } else if (std.mem.eql(u8, command, "dns")) { + try cmdDns(allocator, domain, format orelse "text"); } else if (std.mem.eql(u8, command, "domains")) { try cmdDomains(allocator, format orelse "table", account); } else if (std.mem.eql(u8, command, "summary")) { @@ -277,6 +279,211 @@ fn fetchForAccount(allocator: std.mem.Allocator, acct: *const Config.Account, da return .{ .dmarc = counts.dmarc, .tls = counts.tls }; } +fn cmdDns(allocator: std.mem.Allocator, domain_filter: ?[]const u8, format: []const u8) !void { + const cfg = try Config.load(allocator); + defer cfg.deinit(allocator); + + // Collect domains from reports (or use filter) + var domains: std.ArrayList([]const u8) = .empty; + defer { + for (domains.items) |d| allocator.free(d); + domains.deinit(allocator); + } + + if (domain_filter) |d| { + domains.append(allocator, allocator.dupe(u8, d) catch return) catch {}; + } else { + const entries = try loadEntries(allocator, &cfg, null); + defer reports.store.freeReportEntries(allocator, entries); + + var domain_set = std.StringHashMap(void).init(allocator); + defer domain_set.deinit(); + for (entries) |e| { + if (e.domain.len > 0) domain_set.put(e.domain, {}) catch {}; + } + var it = domain_set.iterator(); + while (it.next()) |kv| { + domains.append(allocator, allocator.dupe(u8, kv.key_ptr.*) catch continue) catch {}; + } + } + + const is_json = std.mem.eql(u8, format, "json"); + + const icon_green = "\x1b[38;2;194;255;38m●\x1b[0m"; + const icon_yellow = "\x1b[38;2;255;200;0m●\x1b[0m"; + const icon_red = "\x1b[38;2;255;51;102m●\x1b[0m"; + const check_green = "\x1b[38;2;194;255;38m✓\x1b[0m "; + const check_yellow = "\x1b[38;2;255;200;0m△\x1b[0m "; + const check_red = "\x1b[38;2;255;51;102m✗\x1b[0m "; + const not_found = "\x1b[2m(not found)\x1b[0m"; + + var json_buf: std.ArrayList(u8) = .empty; + defer json_buf.deinit(allocator); + var json_first = true; + if (is_json) json_buf.appendSlice(allocator, "[") catch {}; + + for (domains.items) |domain| { + var buf: [2048]u8 = undefined; + + // Query all records first to determine domain status + var dmarc_txt: ?[]const u8 = null; + defer if (dmarc_txt) |t| allocator.free(t); + var spf_txt: ?[]const u8 = null; + defer if (spf_txt) |t| allocator.free(t); + var dkim_txt: ?[]const u8 = null; + defer if (dkim_txt) |t| allocator.free(t); + var dkim_selector: []const u8 = ""; + var mta_sts_txt: ?[]const u8 = null; + defer if (mta_sts_txt) |t| allocator.free(t); + var tls_rpt_txt: ?[]const u8 = null; + defer if (tls_rpt_txt) |t| allocator.free(t); + + // DMARC + { + const qname = std.fmt.allocPrint(allocator, "_dmarc.{s}", .{domain}) catch continue; + defer allocator.free(qname); + dmarc_txt = reports.ipinfo.queryTxt(allocator, qname) catch null; + } + + // SPF + { + if (reports.ipinfo.queryTxt(allocator, domain)) |txt| { + if (std.mem.indexOf(u8, txt, "v=spf1") != null) { + spf_txt = txt; + } else { + allocator.free(txt); + } + } else |_| {} + } + + // DKIM + for ([_][]const u8{ "default", "google", "selector1", "selector2", "s1", "s2", "dkim", "mail" }) |selector| { + const qname = std.fmt.allocPrint(allocator, "{s}._domainkey.{s}", .{ selector, domain }) catch continue; + defer allocator.free(qname); + if (reports.ipinfo.queryTxt(allocator, qname)) |txt| { + if (std.mem.indexOf(u8, txt, "DKIM1") != null or std.mem.indexOf(u8, txt, "p=") != null) { + dkim_txt = txt; + dkim_selector = selector; + break; + } else { + allocator.free(txt); + } + } else |_| {} + } + + // MTA-STS + { + const qname = std.fmt.allocPrint(allocator, "_mta-sts.{s}", .{domain}) catch continue; + defer allocator.free(qname); + mta_sts_txt = reports.ipinfo.queryTxt(allocator, qname) catch null; + } + + // TLS-RPT + { + const qname = std.fmt.allocPrint(allocator, "_smtp._tls.{s}", .{domain}) catch continue; + defer allocator.free(qname); + tls_rpt_txt = reports.ipinfo.queryTxt(allocator, qname) catch null; + } + + const dmarc_policy_weak = if (dmarc_txt) |t| reports.stats.isDmarcPolicyWeak(t) else false; + const spf_weak = if (spf_txt) |t| reports.stats.isSpfWeak(t) else false; + const has_dmarc = dmarc_txt != null; + const has_spf = spf_txt != null; + const has_dkim = dkim_txt != null; + const dns_status = reports.stats.evaluateDnsStatus(has_dmarc, has_spf, has_dkim, dmarc_policy_weak, spf_weak); + + if (is_json) { + if (!json_first) json_buf.appendSlice(allocator, ",") catch continue; + json_first = false; + + const status_str = dns_status.label(); + const esc = reports.stats.jsonEscape; + const dmarc_s = if (dmarc_txt) |t| esc(allocator, t) else ""; + defer if (dmarc_txt != null and dmarc_s.ptr != dmarc_txt.?.ptr) allocator.free(dmarc_s); + const spf_s = if (spf_txt) |t| esc(allocator, t) else ""; + defer if (spf_txt != null and spf_s.ptr != spf_txt.?.ptr) allocator.free(spf_s); + const dkim_s = if (dkim_txt) |t| esc(allocator, t) else ""; + defer if (dkim_txt != null and dkim_s.ptr != dkim_txt.?.ptr) allocator.free(dkim_s); + const mta_s = if (mta_sts_txt) |t| esc(allocator, t) else ""; + defer if (mta_sts_txt != null and mta_s.ptr != mta_sts_txt.?.ptr) allocator.free(mta_s); + const tls_s = if (tls_rpt_txt) |t| esc(allocator, t) else ""; + defer if (tls_rpt_txt != null and tls_s.ptr != tls_rpt_txt.?.ptr) allocator.free(tls_s); + + const line = std.fmt.allocPrint(allocator, "{{\"domain\":\"{s}\",\"status\":\"{s}\",\"dmarc\":\"{s}\",\"spf\":\"{s}\",\"dkim\":\"{s}\",\"dkim_selector\":\"{s}\",\"mta_sts\":\"{s}\",\"tls_rpt\":\"{s}\"}}", .{ + domain, status_str, dmarc_s, spf_s, dkim_s, dkim_selector, mta_s, tls_s, + }) catch continue; + defer allocator.free(line); + json_buf.appendSlice(allocator, line) catch continue; + } else { + const icon = switch (dns_status) { + .ok => icon_green, + .warning => icon_yellow, + .critical => icon_red, + }; + + // Print domain header + const hdr = std.fmt.bufPrint(&buf, " {s} {s}\n", .{ icon, domain }) catch ""; + stdout_file.writeAll(hdr) catch {}; + + // DMARC + if (dmarc_txt) |txt| { + const ci = if (dmarc_policy_weak) check_yellow else check_green; + const msg = std.fmt.bufPrint(&buf, " {s} DMARC: {s}\n", .{ ci, txt }) catch ""; + stdout_file.writeAll(msg) catch {}; + } else { + const msg = std.fmt.bufPrint(&buf, " {s} DMARC: {s}\n", .{ check_red, not_found }) catch ""; + stdout_file.writeAll(msg) catch {}; + } + + // SPF + if (spf_txt) |txt| { + const ci = if (spf_weak) check_yellow else check_green; + const msg = std.fmt.bufPrint(&buf, " {s} SPF: {s}\n", .{ ci, txt }) catch ""; + stdout_file.writeAll(msg) catch {}; + } else { + const msg = std.fmt.bufPrint(&buf, " {s} SPF: {s}\n", .{ check_red, not_found }) catch ""; + stdout_file.writeAll(msg) catch {}; + } + + // DKIM + if (dkim_txt) |txt| { + const trunc = if (txt.len > 60) txt[0..60] else txt; + const msg = std.fmt.allocPrint(allocator, " {s} DKIM: {s}... ({s}._domainkey)\n", .{ check_green, trunc, dkim_selector }) catch continue; + defer allocator.free(msg); + stdout_file.writeAll(msg) catch {}; + } else { + const msg = std.fmt.bufPrint(&buf, " {s} DKIM: {s}\n", .{ check_red, not_found }) catch ""; + stdout_file.writeAll(msg) catch {}; + } + + // MTA-STS + if (mta_sts_txt) |txt| { + const msg = std.fmt.bufPrint(&buf, " {s} MTA-STS: {s}\n", .{ check_green, txt }) catch ""; + stdout_file.writeAll(msg) catch {}; + } else { + const msg = std.fmt.bufPrint(&buf, " {s} MTA-STS: {s}\n", .{ check_red, not_found }) catch ""; + stdout_file.writeAll(msg) catch {}; + } + + // TLS-RPT + if (tls_rpt_txt) |txt| { + const msg = std.fmt.bufPrint(&buf, " {s} TLS-RPT: {s}\n", .{ check_green, txt }) catch ""; + stdout_file.writeAll(msg) catch {}; + } else { + const msg = std.fmt.bufPrint(&buf, " {s} TLS-RPT: {s}\n", .{ check_red, not_found }) catch ""; + stdout_file.writeAll(msg) catch {}; + } + + stdout_file.writeAll("\n") catch {}; + } + } + + if (is_json) { + json_buf.appendSlice(allocator, "]\n") catch {}; + stdout_file.writeAll(json_buf.items) catch {}; + } +} + fn cmdDomains(allocator: std.mem.Allocator, format: []const u8, account: ?[]const u8) !void { const cfg = try Config.load(allocator); defer cfg.deinit(allocator); @@ -631,6 +838,10 @@ const CheckResult = struct { dmarc_total: u64 = 0, dmarc_pass: u64 = 0, dmarc_fail: u64 = 0, + // Failure pattern breakdown + dkim_only_fail: u64 = 0, // DKIM fail, SPF pass + spf_only_fail: u64 = 0, // DKIM pass, SPF fail + both_fail: u64 = 0, // Both DKIM and SPF fail // TLS-RPT tls_reports: u64 = 0, tls_total_success: u64 = 0, @@ -708,20 +919,31 @@ fn cmdCheck( result.dmarc_total += rec.count; const dkim_pass = std.mem.eql(u8, rec.dkim_eval, "pass"); const spf_pass = std.mem.eql(u8, rec.spf_eval, "pass"); - if (dkim_pass or spf_pass) { - result.dmarc_pass += rec.count; + if (reports.stats.classifyFailure(dkim_pass, spf_pass)) |ft| { + switch (ft) { + .both_fail => result.both_fail += rec.count, + .dkim_only_fail => result.dkim_only_fail += rec.count, + .spf_only_fail => result.spf_only_fail += rec.count, + } + if (ft == .both_fail) { + // Only both-fail counts as DMARC failure + result.dmarc_fail += rec.count; + dmarc_fails.append(allocator, .{ + .account = entry.account_name, + .domain = entry.domain, + .org = entry.org_name, + .source_ip = allocator.dupe(u8, rec.source_ip) catch continue, + .count = rec.count, + .dkim = allocator.dupe(u8, rec.dkim_eval) catch continue, + .spf = allocator.dupe(u8, rec.spf_eval) catch continue, + .header_from = allocator.dupe(u8, rec.header_from) catch continue, + }) catch {}; + } else { + // Single-mechanism fail still passes DMARC (one pass is enough) + result.dmarc_pass += rec.count; + } } else { - result.dmarc_fail += rec.count; - dmarc_fails.append(allocator, .{ - .account = entry.account_name, - .domain = entry.domain, - .org = entry.org_name, - .source_ip = allocator.dupe(u8, rec.source_ip) catch continue, - .count = rec.count, - .dkim = allocator.dupe(u8, rec.dkim_eval) catch continue, - .spf = allocator.dupe(u8, rec.spf_eval) catch continue, - .header_from = allocator.dupe(u8, rec.header_from) catch continue, - }) catch {}; + result.dmarc_pass += rec.count; } } }, @@ -859,6 +1081,22 @@ fn writeCheckText( }) catch ""; stdout_file.writeAll(status_msg) catch {}; + // Failure pattern breakdown + if (result.dkim_only_fail > 0 or result.spf_only_fail > 0 or result.both_fail > 0) { + stdout_file.writeAll("\nAuth mechanism breakdown (single-mechanism fails still pass DMARC):\n") catch {}; + const breakdown = [_]struct { ft: reports.stats.FailureType, count: u64 }{ + .{ .ft = .both_fail, .count = result.both_fail }, + .{ .ft = .dkim_only_fail, .count = result.dkim_only_fail }, + .{ .ft = .spf_only_fail, .count = result.spf_only_fail }, + }; + for (breakdown) |b| { + if (b.count > 0) { + const msg = std.fmt.bufPrint(&buf, " {s}: {d} messages ({s})\n", .{ b.ft.label(), b.count, b.ft.hint() }) catch ""; + stdout_file.writeAll(msg) catch {}; + } + } + } + if (stale) { const stale_msg = std.fmt.bufPrint(&buf, "WARNING: No reports received in the last {d} days (latest: {s})\n", .{ max_age, if (result.latest_date.len > 0) result.latest_date else "none", @@ -932,8 +1170,8 @@ fn writeCheckJson( try buf.appendSlice(allocator, header); const dmarc_part = try std.fmt.allocPrint(allocator, - \\"dmarc":{{"reports":{d},"total":{d},"pass":{d},"fail":{d},"fail_rate":{d}}}, - , .{ result.dmarc_reports, result.dmarc_total, result.dmarc_pass, result.dmarc_fail, dmarc_fail_rate }); + \\"dmarc":{{"reports":{d},"total":{d},"pass":{d},"fail":{d},"fail_rate":{d},"dkim_only_fail":{d},"spf_only_fail":{d},"both_fail":{d}}}, + , .{ result.dmarc_reports, result.dmarc_total, result.dmarc_pass, result.dmarc_fail, dmarc_fail_rate, result.dkim_only_fail, result.spf_only_fail, result.both_fail }); defer allocator.free(dmarc_part); try buf.appendSlice(allocator, dmarc_part); @@ -1630,6 +1868,7 @@ fn printUsage() void { \\ show Show report details \\ summary Show summary statistics \\ check Check for anomalies (exit 0=OK, 1=WARN, 2=CRIT) + \\ dns Show DNS records (DMARC/SPF/DKIM/MTA-STS/TLS-RPT) \\ domains List domains \\ version Show version \\ help Show this help diff --git a/src/stats.zig b/src/stats.zig index 4444caf..15e9104 100644 --- a/src/stats.zig +++ b/src/stats.zig @@ -597,3 +597,209 @@ test "hashIncOwned handles empty string key" { hashIncOwned(alloc, &map, "", 3); try std.testing.expectEqual(@as(u64, 8), map.get("").?); } + +// MARK: - JSON string escaping + +/// Escape a string for embedding in a JSON string value. +/// Returns the original slice if no escaping is needed, or a new allocation. +/// Caller must free the result if it differs from input. +pub fn jsonEscape(alloc: Allocator, input: []const u8) []const u8 { + var needs_escape = false; + for (input) |ch| { + if (ch == '"' or ch == '\\' or ch < 0x20) { + needs_escape = true; + break; + } + } + if (!needs_escape) return input; + + var out: std.ArrayList(u8) = .empty; + for (input) |ch| { + switch (ch) { + '"' => out.appendSlice(alloc, "\\\"") catch return input, + '\\' => out.appendSlice(alloc, "\\\\") catch return input, + '\n' => out.appendSlice(alloc, "\\n") catch return input, + '\r' => out.appendSlice(alloc, "\\r") catch return input, + '\t' => out.appendSlice(alloc, "\\t") catch return input, + else => if (ch < 0x20) { + const hex = "0123456789abcdef"; + out.appendSlice(alloc, "\\u00") catch return input; + out.append(alloc, hex[ch >> 4]) catch return input; + out.append(alloc, hex[ch & 0x0f]) catch return input; + } else { + out.append(alloc, ch) catch return input; + }, + } + } + return out.toOwnedSlice(alloc) catch input; +} + +// MARK: - DNS status evaluation + +pub const DnsStatus = enum { + ok, + warning, + critical, + + pub fn label(self: DnsStatus) []const u8 { + return switch (self) { + .ok => "ok", + .warning => "warning", + .critical => "critical", + }; + } +}; + +/// Evaluate overall DNS health for a domain based on record presence and strength. +pub fn evaluateDnsStatus( + has_dmarc: bool, + has_spf: bool, + has_dkim: bool, + dmarc_policy_weak: bool, + spf_weak: bool, +) DnsStatus { + if (!has_dmarc or !has_spf or !has_dkim) return .critical; + if (dmarc_policy_weak or spf_weak) return .warning; + return .ok; +} + +/// Check if a DMARC policy is weak (p=none means monitor-only, no enforcement). +/// Carefully matches only the "p=" tag, not "sp=" or "np=". +pub fn isDmarcPolicyWeak(dmarc_txt: []const u8) bool { + var i: usize = 0; + while (i < dmarc_txt.len) { + // Find "p=" + const pos = std.mem.indexOf(u8, dmarc_txt[i..], "p=") orelse return false; + const abs = i + pos; + // Make sure it's the "p" tag, not "sp=" or "np=" + if (abs == 0 or dmarc_txt[abs - 1] == ';' or dmarc_txt[abs - 1] == ' ') { + // Check the value after "p=" + const val_start = abs + 2; + if (val_start + 4 <= dmarc_txt.len and std.mem.eql(u8, dmarc_txt[val_start .. val_start + 4], "none")) { + return true; + } + } + i = abs + 2; + } + return false; +} + +/// Check if an SPF record uses soft fail (~all instead of -all). +pub fn isSpfWeak(spf_txt: []const u8) bool { + return std.mem.indexOf(u8, spf_txt, "~all") != null; +} + +// MARK: - DMARC failure classification + +pub const FailureType = enum { + both_fail, + dkim_only_fail, + spf_only_fail, + + pub fn label(self: FailureType) []const u8 { + return switch (self) { + .both_fail => "DKIM+SPF fail", + .dkim_only_fail => "DKIM fail only", + .spf_only_fail => "SPF fail only", + }; + } + + pub fn hint(self: FailureType) []const u8 { + return switch (self) { + .both_fail => "needs DKIM and SPF setup", + .dkim_only_fail => "needs DKIM setup", + .spf_only_fail => "needs SPF setup", + }; + } +}; + +/// Classify a DMARC failure based on DKIM and SPF evaluation results. +/// Returns null if both pass (not a failure). +pub fn classifyFailure(dkim_pass: bool, spf_pass: bool) ?FailureType { + if (dkim_pass and spf_pass) return null; + if (!dkim_pass and !spf_pass) return .both_fail; + if (!dkim_pass) return .dkim_only_fail; + return .spf_only_fail; +} + +// MARK: - DNS status tests + +test "evaluateDnsStatus returns ok when all records present and strong" { + try std.testing.expectEqual(DnsStatus.ok, evaluateDnsStatus(true, true, true, false, false)); +} + +test "evaluateDnsStatus returns warning for weak DMARC policy" { + try std.testing.expectEqual(DnsStatus.warning, evaluateDnsStatus(true, true, true, true, false)); +} + +test "evaluateDnsStatus returns warning for weak SPF" { + try std.testing.expectEqual(DnsStatus.warning, evaluateDnsStatus(true, true, true, false, true)); +} + +test "evaluateDnsStatus returns warning when both weak" { + try std.testing.expectEqual(DnsStatus.warning, evaluateDnsStatus(true, true, true, true, true)); +} + +test "evaluateDnsStatus returns critical when DKIM missing" { + try std.testing.expectEqual(DnsStatus.critical, evaluateDnsStatus(true, true, false, false, false)); +} + +test "evaluateDnsStatus returns critical when DMARC missing" { + try std.testing.expectEqual(DnsStatus.critical, evaluateDnsStatus(false, true, true, false, false)); +} + +test "evaluateDnsStatus returns critical when SPF missing" { + try std.testing.expectEqual(DnsStatus.critical, evaluateDnsStatus(true, false, true, false, false)); +} + +test "evaluateDnsStatus returns critical over warning when record missing and weak" { + try std.testing.expectEqual(DnsStatus.critical, evaluateDnsStatus(true, true, false, true, true)); +} + +test "isDmarcPolicyWeak detects p=none" { + try std.testing.expect(isDmarcPolicyWeak("v=DMARC1; p=none; rua=mailto:x@example.com")); +} + +test "isDmarcPolicyWeak returns false for p=quarantine" { + try std.testing.expect(!isDmarcPolicyWeak("v=DMARC1; p=quarantine; rua=mailto:x@example.com")); +} + +test "isDmarcPolicyWeak returns false for p=reject" { + try std.testing.expect(!isDmarcPolicyWeak("v=DMARC1; p=reject; rua=mailto:x@example.com")); +} + +test "isDmarcPolicyWeak returns false for sp=none with strong p" { + try std.testing.expect(!isDmarcPolicyWeak("v=DMARC1; p=reject; sp=none")); +} + +test "isDmarcPolicyWeak returns false for np=none with strong p" { + try std.testing.expect(!isDmarcPolicyWeak("v=DMARC1; p=quarantine; np=none")); +} + +test "isDmarcPolicyWeak detects p=none at start of record" { + try std.testing.expect(isDmarcPolicyWeak("p=none; rua=mailto:x@example.com")); +} + +test "isSpfWeak detects ~all" { + try std.testing.expect(isSpfWeak("v=spf1 include:_spf.google.com ~all")); +} + +test "isSpfWeak returns false for -all" { + try std.testing.expect(!isSpfWeak("v=spf1 ip4:1.2.3.4 -all")); +} + +test "classifyFailure returns null when both pass" { + try std.testing.expectEqual(@as(?FailureType, null), classifyFailure(true, true)); +} + +test "classifyFailure returns both_fail" { + try std.testing.expectEqual(FailureType.both_fail, classifyFailure(false, false).?); +} + +test "classifyFailure returns dkim_only_fail" { + try std.testing.expectEqual(FailureType.dkim_only_fail, classifyFailure(false, true).?); +} + +test "classifyFailure returns spf_only_fail" { + try std.testing.expectEqual(FailureType.spf_only_fail, classifyFailure(true, false).?); +}