agentd-net 1.0.0

Minimal blocking HTTP/1.1 + SSE over Read+Write, unix/tls/vsock connects, SSRF guard
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
// SPDX-License-Identifier: AGPL-3.0-only
//! SSRF classifier (RFC 0012 — security posture, §"SSRF guard").
//!
//! A *pure* address classifier plus a DNS-resolving host guard. The
//! acceptance bar from assessment §4 M6 is blunt: "HTTP client refuses
//! RFC-1918 / link-local by default". This module is the mechanism; it is
//! composed at every call site that introduces a model/agent/peer-supplied
//! URL (A2A push targets, `http` workflow nodes), while the only
//! operator-configured outbound (`intel/client.rs`) is exempt.
//!
//! ## Resolve once, dial what you vetted
//!
//! A guard that resolves a name, likes the answer, and then lets the
//! caller dial the *name* is decorative: the connect re-resolves, and an
//! attacker who controls the authoritative DNS answers the guard with a
//! public address and the connect with `169.254.169.254`. That is DNS
//! rebinding, and it defeats an address check that does not carry its
//! result forward.
//!
//! So the guard hands back the addresses it vetted
//! ([`resolve_guarded`]) and the dial takes *addresses*, never a name
//! ([`connect_vetted`] / [`connect_addrs`], which re-assert [`is_global`]
//! on every address immediately before the syscall). TLS and the `Host`
//! header stay on the original hostname — connect by IP, verify by name —
//! so SNI and certificate validation are unaffected.
//!
//! [`guard_host`] is retained for the yes/no admission check at
//! *registration* time, where there is no socket to dial yet; it is not
//! sufficient on its own at delivery time.
//!
//! ## What "non-global" means here
//!
//! [`is_global`] returns `false` — i.e. the address is *blocked* — for
//! any address an attacker could pivot to from inside the appliance's
//! network namespace:
//!
//! * loopback (`127.0.0.0/8`, `::1`)
//! * RFC-1918 private (`10/8`, `172.16/12`, `192.168/16`)
//! * link-local (`169.254/16`, `fe80::/10`) — this is the cloud
//!   metadata range (`169.254.169.254`)
//! * IPv6 unique-local (`fc00::/7`)
//! * unspecified (`0.0.0.0`, `::`)
//! * multicast and the IPv4 limited broadcast (`255.255.255.255`)
//! * "this network" `0.0.0.0/8` and the IETF/benchmark documentation
//!   ranges, which never route on the public Internet
//! * **any IPv4-mapped / IPv4-compatible IPv6** whose embedded v4
//!   address is itself non-global — `::ffff:127.0.0.1` and friends are
//!   a classic guard bypass, so we unwrap before classifying.
//!
//! We deliberately do NOT lean on `std`'s unstable `IpAddr::is_global`
//! (feature `ip`, issue #27709) — it is not available on our MSRV and
//! its semantics drift. Every range below is spelled out by hand from
//! primitives that are stable on Rust 1.88, in the same
//! enumerate-the-bytes spirit as the rest of the crate.
//!
//! ## Logging posture
//!
//! Hosts and IPs are *operational* identifiers, not tool/instruction
//! content, so the diagnostic carries the host and the offending class
//! — never request bodies, headers, or secrets. The codebase is
//! content-capture-off by default and this module keeps that contract.

use std::io;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream, ToSocketAddrs};
use std::time::Duration;

// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------

/// A host failed the SSRF guard, or could not be resolved at all.
///
/// `Clone`/`Eq` so callers can compare and surface it without owning a
/// socket; `host` is the operator/tool-supplied authority (not secret),
/// `reason` is a short human class string (e.g. `"loopback"`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SsrfError {
    /// The host authority that was guarded (no port).
    pub host: String,
    /// Short class of the failure, e.g. `"link-local 169.254.169.254"`.
    pub reason: String,
}

impl std::fmt::Display for SsrfError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "host `{}` rejected by SSRF guard: {}",
            self.host, self.reason
        )
    }
}

impl std::error::Error for SsrfError {}

fn reject(host: &str, reason: impl Into<String>) -> SsrfError {
    SsrfError {
        host: host.to_string(),
        reason: reason.into(),
    }
}

// ---------------------------------------------------------------------------
// Pure classifier
// ---------------------------------------------------------------------------

/// `true` iff `ip` is a globally routable unicast address that is safe
/// to dial from inside the appliance — i.e. *not* in any of the blocked
/// ranges documented on this module.
///
/// Pure: no DNS, no I/O. This is the single source of truth; the host
/// guard composes it over every resolved address.
pub fn is_global(ip: IpAddr) -> bool {
    match ip {
        IpAddr::V4(v4) => is_global_v4(v4),
        IpAddr::V6(v6) => is_global_v6(v6),
    }
}

/// IPv4 classification. Blocked ranges are spelled out from RFC-3330 /
/// RFC-1918 / RFC-3927 rather than via `std`'s unstable helpers.
fn is_global_v4(ip: Ipv4Addr) -> bool {
    let [a, b, _, _] = ip.octets();

    // "This host on this network" — 0.0.0.0/8 (covers 0.0.0.0).
    if a == 0 {
        return false;
    }
    // Loopback 127.0.0.0/8, private 10/8 + 172.16/12 + 192.168/16,
    // link-local 169.254/16, broadcast, multicast 224/4 + reserved
    // 240/4, all unspecified — std covers these and they are stable.
    if ip.is_loopback()
        || ip.is_private()
        || ip.is_link_local()
        || ip.is_broadcast()
        || ip.is_multicast()
        || ip.is_unspecified()
        || ip.is_documentation()
    {
        return false;
    }
    // Carrier-grade NAT (RFC-6598) 100.64.0.0/10 — shared address
    // space, not globally routable; `is_shared` is unstable so unfold
    // the prefix by hand.
    if a == 100 && (64..=127).contains(&b) {
        return false;
    }
    // Reserved 240.0.0.0/4 (minus the broadcast already caught) — never
    // a routable destination.
    if a >= 240 {
        return false;
    }
    true
}

/// IPv6 classification. We first peel IPv4-mapped (`::ffff:0:0/96`) and
/// IPv4-compatible (`::/96`) forms back to v4 and re-run the v4 rules —
/// this is the bypass that bites naive guards.
fn is_global_v6(ip: Ipv6Addr) -> bool {
    // `::ffff:a.b.c.d` — classify the embedded v4 address.
    if let Some(v4) = ip.to_ipv4_mapped() {
        return is_global_v4(v4);
    }
    // `::a.b.c.d` (deprecated IPv4-compatible, plus ::1 / ::). `to_ipv4`
    // also yields the mapped form, but the mapped case is handled
    // above; here it catches the compatible range. ::1 and :: classify
    // as loopback/unspecified v4-side too, but we also guard them
    // directly below for clarity.
    if let Some(v4) = ip.to_ipv4() {
        return is_global_v4(v4);
    }

    if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
        return false;
    }

    let segments = ip.segments();
    // Link-local unicast fe80::/10 (top 10 bits == 1111 1110 10).
    if (segments[0] & 0xffc0) == 0xfe80 {
        return false;
    }
    // Unique-local fc00::/7 (top 7 bits == 1111 110x).
    if (segments[0] & 0xfe00) == 0xfc00 {
        return false;
    }
    // Documentation 2001:db8::/32 — never globally routable.
    if segments[0] == 0x2001 && segments[1] == 0x0db8 {
        return false;
    }
    true
}

// ---------------------------------------------------------------------------
// Host guard (DNS-resolving)
// ---------------------------------------------------------------------------

/// Resolve `host` and reject if *any* resolved address is non-global.
///
/// This is the deny-all-the-aliases stance: a hostname that resolves to
/// both a public and a private address is rejected, because an attacker
/// who controls DNS could otherwise race the second connect (a DNS
/// rebinding pivot).
///
/// **This answers a yes/no question and throws the addresses away**, so
/// it is only sound where there is nothing to dial yet — admission of a
/// push target at registration, config validation. Anything that goes on
/// to open a socket MUST use [`resolve_guarded`] + [`connect_addrs`] (or
/// [`connect_vetted`], which does both), or the connect re-resolves and
/// the check it just passed means nothing.
///
/// `allow_private == true` is the operator escape hatch — it skips the
/// check entirely without even resolving, so trusted localhost/private
/// gateways (the configured intelligence endpoint) keep working. Callers
/// that take a MODEL/AGENT-supplied URL MUST pass `false`.
///
/// Pure-ish: the only side effect is DNS resolution. No bytes are sent.
pub fn guard_host(host: &str, allow_private: bool) -> Result<(), SsrfError> {
    if allow_private {
        return Ok(());
    }
    // Port 0 because we are only classifying: the resolver needs a port
    // grammar and we discard the addresses anyway.
    resolve_guarded(host, 0, false).map(|_| ())
}

// ---------------------------------------------------------------------------
// Resolver seam
// ---------------------------------------------------------------------------

/// How a host is turned into addresses. A plain `fn` pointer, not a
/// trait object: the only production implementation is [`std_resolve`],
/// and the seam exists so a test can install a *hostile* resolver that
/// answers the guard and the dial differently — the rebinding shape this
/// module has to survive.
pub type ResolveFn = fn(&str, u16) -> io::Result<Vec<SocketAddr>>;

/// The production resolver: `std`'s `ToSocketAddrs`, with the bracketed
/// IPv6 literal form (`[::1]`, as URLs write it) unwrapped first because
/// `ToSocketAddrs` does not accept the brackets on a bare host.
pub fn std_resolve(host: &str, port: u16) -> io::Result<Vec<SocketAddr>> {
    let bare = host
        .strip_prefix('[')
        .and_then(|s| s.strip_suffix(']'))
        .unwrap_or(host);
    // An IP literal short-circuits DNS entirely — no syscall, and no
    // opportunity for a resolver to answer with something else.
    if let Ok(ip) = bare.parse::<IpAddr>() {
        return Ok(vec![SocketAddr::new(ip, port)]);
    }
    (host, port).to_socket_addrs().map(|it| it.collect())
}

// ---------------------------------------------------------------------------
// Resolve-once guard + dial-what-you-vetted
// ---------------------------------------------------------------------------

/// Resolve `host:port` **once** and return the addresses, having rejected
/// the whole host if *any* of them is non-global.
///
/// The returned vector is the only thing a caller may dial: passing the
/// name to a second resolution is precisely the rebinding hole this
/// exists to close.
///
/// `allow_private` still resolves (there has to be something to connect
/// to) but skips the classification, matching [`guard_host`]'s escape
/// hatch.
pub fn resolve_guarded(
    host: &str,
    port: u16,
    allow_private: bool,
) -> Result<Vec<SocketAddr>, SsrfError> {
    resolve_guarded_with(host, port, allow_private, std_resolve)
}

/// [`resolve_guarded`] against an injected resolver. Public so the
/// rebinding regression test can drive both halves — guard and dial —
/// through a resolver that changes its mind between them.
pub fn resolve_guarded_with(
    host: &str,
    port: u16,
    allow_private: bool,
    resolve: ResolveFn,
) -> Result<Vec<SocketAddr>, SsrfError> {
    if host.is_empty() {
        return Err(reject(host, "empty host"));
    }
    let addrs = resolve(host, port).map_err(|e| reject(host, format!("resolve failed: {e}")))?;
    if addrs.is_empty() {
        return Err(reject(host, "no addresses resolved"));
    }
    if !allow_private {
        for sa in &addrs {
            check_addr(host, sa.ip())?;
        }
    }
    Ok(addrs)
}

/// Dial one of `addrs`, re-asserting the classifier on every entry first.
///
/// The re-check is not redundant paranoia: this is the last instruction
/// before the syscall, so it is the only place that can promise the bytes
/// go somewhere global. A caller that hands over an address list built
/// any other way (a cached answer, a redirect target) gets the same
/// refusal, and one non-global entry refuses the *whole* dial rather than
/// falling through to the next address — the same deny-all-the-aliases
/// stance [`resolve_guarded`] takes, so a mixed answer cannot be raced.
///
/// `host` is carried for diagnostics only. TLS/SNI and the `Host` header
/// remain the caller's business and must stay on the original hostname.
pub fn connect_addrs(
    host: &str,
    addrs: &[SocketAddr],
    timeout: Duration,
    allow_private: bool,
) -> io::Result<TcpStream> {
    if addrs.is_empty() {
        return Err(io::Error::new(
            io::ErrorKind::NotFound,
            format!("no vetted addresses for {host}"),
        ));
    }
    if !allow_private {
        for sa in addrs {
            if let Err(e) = check_addr(host, sa.ip()) {
                return Err(io::Error::new(
                    io::ErrorKind::PermissionDenied,
                    e.to_string(),
                ));
            }
        }
    }
    // Every address was vetted above, so trying the next one on a
    // connect failure cannot widen the target set — it is only
    // dual-stack fallback.
    let mut last: Option<io::Error> = None;
    for sa in addrs {
        match TcpStream::connect_timeout(sa, timeout) {
            Ok(stream) => {
                stream.set_read_timeout(Some(timeout))?;
                stream.set_write_timeout(Some(timeout))?;
                stream.set_nodelay(true).ok();
                return Ok(stream);
            }
            Err(e) => last = Some(e),
        }
    }
    Err(last.unwrap_or_else(|| {
        io::Error::new(io::ErrorKind::NotFound, format!("cannot connect to {host}"))
    }))
}

/// Guard and dial in one step: resolve once, vet, connect to a vetted
/// address. This is what a model/peer-supplied URL must use instead of
/// `http::connect_tcp`, which resolves the name a second time.
pub fn connect_vetted(
    host: &str,
    port: u16,
    timeout: Duration,
    allow_private: bool,
) -> io::Result<TcpStream> {
    connect_vetted_with(host, port, timeout, allow_private, std_resolve)
}

/// [`connect_vetted`] against an injected resolver — the test seam.
pub fn connect_vetted_with(
    host: &str,
    port: u16,
    timeout: Duration,
    allow_private: bool,
    resolve: ResolveFn,
) -> io::Result<TcpStream> {
    let addrs = resolve_guarded_with(host, port, allow_private, resolve)
        .map_err(|e| io::Error::new(io::ErrorKind::PermissionDenied, e.to_string()))?;
    connect_addrs(host, &addrs, timeout, allow_private)
}

/// Classify one resolved address, turning a non-global result into a
/// typed rejection with a short class string.
fn check_addr(host: &str, ip: IpAddr) -> Result<(), SsrfError> {
    if is_global(ip) {
        Ok(())
    } else {
        Err(reject(host, format!("{} ({ip})", class_of(ip))))
    }
}

/// Best-effort human label for *why* an address is non-global. Purely
/// cosmetic — `is_global` remains the authority on the boolean.
fn class_of(ip: IpAddr) -> &'static str {
    match ip {
        IpAddr::V4(v4) => {
            if v4.is_unspecified() {
                "unspecified"
            } else if v4.is_loopback() {
                "loopback"
            } else if v4.is_private() {
                "private (RFC-1918)"
            } else if v4.is_link_local() {
                "link-local"
            } else if v4.is_broadcast() {
                "broadcast"
            } else if v4.is_multicast() {
                "multicast"
            } else {
                "reserved"
            }
        }
        IpAddr::V6(v6) => {
            if let Some(v4) = v6.to_ipv4_mapped().or_else(|| v6.to_ipv4()) {
                return class_of(IpAddr::V4(v4));
            }
            if v6.is_unspecified() {
                "unspecified"
            } else if v6.is_loopback() {
                "loopback"
            } else if v6.is_multicast() {
                "multicast"
            } else {
                "link-local/unique-local"
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Tests — LITERAL IPs only, never DNS.
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr {
        IpAddr::V4(Ipv4Addr::new(a, b, c, d))
    }

    fn v6(s: &str) -> IpAddr {
        IpAddr::V6(s.parse::<Ipv6Addr>().expect("test ipv6 literal"))
    }

    #[test]
    fn public_v4_is_global() {
        assert!(is_global(v4(8, 8, 8, 8)));
        assert!(is_global(v4(1, 1, 1, 1)));
        assert!(is_global(v4(93, 184, 216, 34))); // example.com historic
        assert!(is_global(v4(172, 15, 255, 255))); // just below 172.16/12
        assert!(is_global(v4(172, 32, 0, 1))); // just above 172.31
        assert!(is_global(v4(11, 0, 0, 1))); // just above 10/8
        assert!(is_global(v4(192, 167, 255, 255))); // just below 192.168/16
        assert!(is_global(v4(192, 169, 0, 1))); // just above 192.168/16
        assert!(is_global(v4(100, 63, 255, 255))); // just below CGNAT 100.64/10
        assert!(is_global(v4(100, 128, 0, 1))); // just above CGNAT
    }

    #[test]
    fn loopback_blocked() {
        assert!(!is_global(v4(127, 0, 0, 1)));
        assert!(!is_global(v4(127, 255, 255, 255)));
        assert!(!is_global(v6("::1")));
    }

    #[test]
    fn rfc1918_blocked() {
        // 10/8
        assert!(!is_global(v4(10, 0, 0, 0)));
        assert!(!is_global(v4(10, 255, 255, 255)));
        // 172.16/12
        assert!(!is_global(v4(172, 16, 0, 0)));
        assert!(!is_global(v4(172, 16, 0, 1)));
        assert!(!is_global(v4(172, 31, 255, 255)));
        // 192.168/16
        assert!(!is_global(v4(192, 168, 0, 1)));
        assert!(!is_global(v4(192, 168, 255, 255)));
    }

    #[test]
    fn link_local_and_metadata_blocked() {
        assert!(!is_global(v4(169, 254, 0, 1)));
        // The cloud metadata endpoint — the whole point of M6.
        assert!(!is_global(v4(169, 254, 169, 254)));
        assert!(!is_global(v4(169, 254, 255, 255)));
        // IPv6 link-local fe80::/10 — both ends of the prefix.
        assert!(!is_global(v6("fe80::1")));
        assert!(!is_global(v6("febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff")));
    }

    #[test]
    fn unique_local_blocked() {
        // fc00::/7 covers fc00:: and fd00::.
        assert!(!is_global(v6("fc00::1")));
        assert!(!is_global(v6("fd12:3456:789a::1")));
        assert!(!is_global(v6("fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")));
    }

    #[test]
    fn unspecified_blocked() {
        assert!(!is_global(v4(0, 0, 0, 0)));
        assert!(!is_global(v4(0, 1, 2, 3))); // 0/8 "this network"
        assert!(!is_global(v6("::")));
    }

    #[test]
    fn multicast_and_broadcast_blocked() {
        assert!(!is_global(v4(224, 0, 0, 1)));
        assert!(!is_global(v4(239, 255, 255, 255)));
        assert!(!is_global(v4(255, 255, 255, 255))); // limited broadcast
        assert!(!is_global(v6("ff02::1")));
    }

    #[test]
    fn reserved_v4_blocked() {
        assert!(!is_global(v4(240, 0, 0, 1)));
        assert!(!is_global(v4(255, 0, 0, 1)));
    }

    #[test]
    fn ipv4_mapped_bypass_is_caught() {
        // ::ffff:127.0.0.1 must classify as loopback, not as a global
        // v6 address. This is the headline bypass.
        assert!(!is_global(v6("::ffff:127.0.0.1")));
        assert!(!is_global(v6("::ffff:10.0.0.1")));
        assert!(!is_global(v6("::ffff:169.254.169.254")));
        assert!(!is_global(v6("::ffff:192.168.1.1")));
        // A mapped *public* v4 stays global.
        assert!(is_global(v6("::ffff:8.8.8.8")));
    }

    #[test]
    fn ipv4_compatible_bypass_is_caught() {
        // ::a.b.c.d (deprecated) — embedded private v4 must be blocked.
        assert!(!is_global(v6("::10.0.0.1")));
        assert!(!is_global(v6("::169.254.169.254")));
    }

    #[test]
    fn public_v6_is_global() {
        assert!(is_global(v6("2606:4700:4700::1111"))); // 1.1.1.1 v6
        assert!(is_global(v6("2001:4860:4860::8888"))); // google dns v6
    }

    #[test]
    fn ipv6_documentation_blocked() {
        assert!(!is_global(v6("2001:db8::1")));
    }

    // --- guard_host over literals (no DNS) ---

    #[test]
    fn guard_rejects_ip_literals() {
        assert!(guard_host("127.0.0.1", false).is_err());
        assert!(guard_host("10.0.0.5", false).is_err());
        assert!(guard_host("169.254.169.254", false).is_err());
        assert!(guard_host("::1", false).is_err());
        assert!(guard_host("[::1]", false).is_err()); // bracketed
        assert!(guard_host("[fe80::1]", false).is_err());
        assert!(guard_host("::ffff:127.0.0.1", false).is_err());
    }

    #[test]
    fn guard_allows_public_ip_literals() {
        assert!(guard_host("8.8.8.8", false).is_ok());
        assert!(guard_host("1.1.1.1", false).is_ok());
        assert!(guard_host("[2606:4700:4700::1111]", false).is_ok());
    }

    #[test]
    fn allow_private_skips_everything() {
        // The operator escape hatch — must not even fail on a literal
        // private address, since the intel endpoint is often localhost.
        assert!(guard_host("127.0.0.1", true).is_ok());
        assert!(guard_host("10.0.0.5", true).is_ok());
        assert!(guard_host("", true).is_ok());
        assert!(guard_host("anything.invalid", true).is_ok());
    }

    #[test]
    fn empty_host_rejected_when_guarded() {
        assert!(guard_host("", false).is_err());
    }

    #[test]
    fn error_carries_host_and_class() {
        let err = guard_host("169.254.169.254", false).unwrap_err();
        assert_eq!(err.host, "169.254.169.254");
        assert!(err.reason.contains("link-local"), "reason: {}", err.reason);
        // Display must surface both without panicking.
        let shown = err.to_string();
        assert!(shown.contains("169.254.169.254"));
        assert!(shown.contains("SSRF guard"));
    }

    #[test]
    fn class_labels_are_specific() {
        assert_eq!(class_of(v4(127, 0, 0, 1)), "loopback");
        assert_eq!(class_of(v4(10, 0, 0, 1)), "private (RFC-1918)");
        assert_eq!(class_of(v4(169, 254, 1, 1)), "link-local");
        assert_eq!(class_of(v4(0, 0, 0, 0)), "unspecified");
        assert_eq!(class_of(v4(224, 0, 0, 1)), "multicast");
        assert_eq!(class_of(v4(255, 255, 255, 255)), "broadcast");
        assert_eq!(class_of(v4(240, 0, 0, 1)), "reserved");
        // mapped v6 borrows the v4 label.
        assert_eq!(class_of(v6("::ffff:10.0.0.1")), "private (RFC-1918)");
    }
}