Skip to main content

easydoc_reader/
security.rs

1//! Security guards for SSRF protection and ZIP bomb / element explosion prevention.
2//!
3//! Mirrors the protection offered by `OfficeCLI`'s `SsrfGuard` and
4//! `GuardDecompressionBomb` / `GuardElementExplosion`.
5//!
6//! These guards should be called at the reader entry point (`from_path` / `from_reader`)
7//! and before ZIP content extraction.
8
9use std::net::{Ipv4Addr, Ipv6Addr};
10
11// ---------------------------------------------------------------------------
12// SsrfGuard
13// ---------------------------------------------------------------------------
14
15/// SSRF (Server-Side Request Forgery) protection configuration.
16///
17/// Validates hyperlinks extracted from DOCX documents against a set of
18/// allowed schemes, blocked hosts, and optionally resolves DNS to check
19/// for private / loopback IP addresses.
20///
21/// # Examples
22///
23/// ```
24/// use easydoc_reader::security::SsrfGuard;
25///
26/// let guard = SsrfGuard::new();
27/// assert!(guard.check_url("https://example.com").is_ok());
28/// assert!(guard.check_url("http://127.0.0.1/admin").is_err());
29/// assert!(guard.check_url("ftp://example.com").is_err());
30/// ```
31#[derive(Debug, Clone)]
32pub struct SsrfGuard {
33    /// Allowed URI schemes (lowercase). Default: `["http", "https", "mailto"]`.
34    pub allowed_schemes: Vec<String>,
35    /// Blocked host names (lowercase). Default: `["localhost"]`.
36    pub blocked_hosts: Vec<String>,
37    /// Whether to resolve DNS names and check the resulting IP addresses
38    /// against private / loopback ranges. Default: `true`.
39    pub resolve_dns: bool,
40    /// Whether to check IP addresses against private / loopback / link-local
41    /// ranges. Default: `true`. Set to `false` in permissive mode.
42    pub check_private_ips: bool,
43}
44
45impl Default for SsrfGuard {
46    fn default() -> Self {
47        Self {
48            allowed_schemes: vec!["http".to_owned(), "https".to_owned(), "mailto".to_owned()],
49            blocked_hosts: vec!["localhost".to_owned()],
50            resolve_dns: true,
51            check_private_ips: true,
52        }
53    }
54}
55
56impl SsrfGuard {
57    /// Creates a guard with the default conservative policy.
58    ///
59    /// Allows `http`, `https`, `mailto` schemes; blocks `localhost` and all
60    /// private / loopback IP ranges; resolves DNS to verify host names.
61    #[must_use]
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Creates a permissive guard that only enforces scheme restrictions.
67    ///
68    /// No hosts are blocked, DNS resolution is disabled, and private IP
69    /// ranges are not checked. Useful when the caller only needs basic
70    /// scheme validation.
71    #[must_use]
72    pub fn permissive() -> Self {
73        Self {
74            allowed_schemes: vec!["http".to_owned(), "https".to_owned(), "mailto".to_owned()],
75            blocked_hosts: Vec::new(),
76            resolve_dns: false,
77            check_private_ips: false,
78        }
79    }
80
81    /// Checks whether the given URL passes all SSRF protection rules.
82    ///
83    /// # Errors
84    ///
85    /// Returns a human-readable error message describing the violation.
86    pub fn check_url(&self, url: &str) -> Result<(), String> {
87        // Split on first ':' to extract the scheme. This handles both
88        // "https://host/path" and "mailto:user@example.com" forms.
89        let (scheme, rest) = url
90            .split_once(':')
91            .ok_or_else(|| "missing scheme".to_owned())?;
92
93        let scheme_lower = scheme.to_ascii_lowercase();
94        if !self.allowed_schemes.iter().any(|s| s == &scheme_lower) {
95            return Err(format!("scheme '{scheme}' not allowed"));
96        }
97
98        // mailto: URLs do not have a network-reachable host.
99        if scheme_lower == "mailto" {
100            return Ok(());
101        }
102
103        // For http/https, rest starts with "//host/path".
104        // Strip the leading "//" if present.
105        let rest = rest.strip_prefix("//").unwrap_or(rest);
106
107        let host_raw = rest.split('/').next().unwrap_or(rest);
108        // For IPv6, the host may be enclosed in brackets: [::1]:8080
109        let host = if host_raw.starts_with('[') {
110            host_raw
111                .split(']')
112                .next()
113                .and_then(|h| h.strip_prefix('['))
114                .unwrap_or(host_raw)
115        } else {
116            host_raw.split(':').next().unwrap_or(host_raw)
117        };
118        if host.is_empty() {
119            return Err("empty host".to_owned());
120        }
121
122        let host_lower = host.to_ascii_lowercase();
123        if self.blocked_hosts.iter().any(|h| h == &host_lower) {
124            return Err(format!("blocked host: {host}"));
125        }
126
127        if self.check_private_ips {
128            if let Ok(v4) = host.parse::<Ipv4Addr>()
129                && Self::is_blocked_ipv4(v4)
130            {
131                return Err(format!("blocked IPv4: {v4}"));
132            }
133            if let Ok(v6) = host.parse::<Ipv6Addr>()
134                && Self::is_blocked_ipv6(v6)
135            {
136                return Err(format!("blocked IPv6: {v6}"));
137            }
138
139            if self.resolve_dns {
140                // Resolve the host name and verify each resulting IP.
141                use std::net::ToSocketAddrs;
142                if let Ok(addrs) = (host, 0u16).to_socket_addrs() {
143                    for addr in addrs {
144                        match addr.ip() {
145                            std::net::IpAddr::V4(v4) => {
146                                if Self::is_blocked_ipv4(v4) {
147                                    return Err(format!("DNS resolved to blocked IPv4: {v4}"));
148                                }
149                            }
150                            std::net::IpAddr::V6(v6) => {
151                                if Self::is_blocked_ipv6(v6) {
152                                    return Err(format!("DNS resolved to blocked IPv6: {v6}"));
153                                }
154                            }
155                        }
156                    }
157                }
158            }
159        }
160
161        Ok(())
162    }
163
164    /// Returns `true` if the IPv4 address falls into a blocked range.
165    ///
166    /// Blocked ranges:
167    /// - `127.0.0.0/8` (loopback)
168    /// - `10.0.0.0/8` (private)
169    /// - `172.16.0.0/12` (private)
170    /// - `192.168.0.0/16` (private)
171    /// - `169.254.0.0/16` (link-local)
172    /// - `100.64.0.0/10` (carrier-grade NAT)
173    /// - `0.0.0.0/8` (reserved)
174    fn is_blocked_ipv4(ip: Ipv4Addr) -> bool {
175        let o = ip.octets();
176        o[0] == 127                                          // 127.0.0.0/8
177            || o[0] == 10                                    // 10.0.0.0/8
178            || (o[0] == 172 && (16..=31).contains(&o[1]))   // 172.16.0.0/12
179            || (o[0] == 192 && o[1] == 168)                 // 192.168.0.0/16
180            || (o[0] == 169 && o[1] == 254)                 // 169.254.0.0/16 link-local
181            || (o[0] == 100 && (64..=127).contains(&o[1]))  // 100.64.0.0/10 CGN
182            || o[0] == 0 // 0.0.0.0/8
183    }
184
185    /// Returns `true` if the IPv6 address falls into a blocked range.
186    ///
187    /// Blocked ranges:
188    /// - Loopback (`::1`)
189    /// - Unspecified (`::`)
190    /// - Unique-local (`fc00::/7`)
191    /// - Link-local (`fe80::/10`)
192    /// - Multicast (`ff00::/8`)
193    fn is_blocked_ipv6(ip: Ipv6Addr) -> bool {
194        if ip.is_loopback() || ip.is_unspecified() {
195            return true;
196        }
197        let s = ip.segments();
198        (s[0] & 0xfe00) == 0xfc00   // fc00::/7 unique-local
199            || (s[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
200            || (s[0] & 0xff00) == 0xff00 // ff00::/8 multicast
201    }
202}
203
204// ---------------------------------------------------------------------------
205// PackageLimits
206// ---------------------------------------------------------------------------
207
208/// ZIP archive size and complexity limits to prevent decompression bombs
209/// and element explosion attacks.
210///
211/// # Examples
212///
213/// ```
214/// use easydoc_reader::security::PackageLimits;
215///
216/// let limits = PackageLimits::new();
217/// // Default: 100 MB total, 50 MB per entry, 100x ratio, 10 000 entries
218/// assert_eq!(limits.max_total_uncompressed, 100 * 1024 * 1024);
219/// ```
220#[derive(Debug, Clone)]
221pub struct PackageLimits {
222    /// Maximum total uncompressed size across all entries (bytes).
223    /// Default: 100 MB.
224    pub max_total_uncompressed: u64,
225    /// Maximum uncompressed size for a single entry (bytes).
226    /// Default: 50 MB.
227    pub max_single_uncompressed: u64,
228    /// Maximum allowed compression ratio (uncompressed / compressed).
229    /// Default: 100.
230    pub max_compression_ratio: u64,
231    /// Maximum number of entries in the archive.
232    /// Default: 10 000.
233    pub max_entries: usize,
234    /// Maximum length of a single filename (bytes).
235    /// Default: 256.
236    pub max_filename_len: usize,
237}
238
239impl Default for PackageLimits {
240    fn default() -> Self {
241        Self {
242            max_total_uncompressed: 100 * 1024 * 1024, // 100 MB
243            max_single_uncompressed: 50 * 1024 * 1024, // 50 MB
244            max_compression_ratio: 100,                // 100x
245            max_entries: 10_000,
246            max_filename_len: 256,
247        }
248    }
249}
250
251impl PackageLimits {
252    /// Creates limits with the default conservative policy.
253    #[must_use]
254    pub fn new() -> Self {
255        Self::default()
256    }
257
258    /// Validates a ZIP archive against all configured limits.
259    ///
260    /// Checks:
261    /// 1. Entry count does not exceed `max_entries`.
262    /// 2. Each filename is within `max_filename_len`.
263    /// 3. No path traversal (`..` or leading `/`) -- Zip Slip prevention.
264    /// 4. No single entry exceeds `max_single_uncompressed`.
265    /// 5. Per-entry compression ratio does not exceed `max_compression_ratio`.
266    /// 6. Total uncompressed size does not exceed `max_total_uncompressed`.
267    /// 7. Overall compression ratio does not exceed `max_compression_ratio`.
268    ///
269    /// # Errors
270    ///
271    /// Returns a human-readable error describing which limit was exceeded.
272    pub fn validate_archive<R: std::io::Read + std::io::Seek>(
273        &self,
274        archive: &mut zip::ZipArchive<R>,
275    ) -> Result<(), String> {
276        let len = archive.len();
277        if len > self.max_entries {
278            return Err(format!("too many entries: {len} > {}", self.max_entries));
279        }
280
281        let mut total_uncompressed: u64 = 0;
282        let mut total_compressed: u64 = 0;
283
284        for i in 0..len {
285            let entry = archive.by_index(i).map_err(|e| e.to_string())?;
286            let name = entry.name();
287
288            if name.len() > self.max_filename_len {
289                return Err(format!(
290                    "filename too long: {} bytes (max {})",
291                    name.len(),
292                    self.max_filename_len
293                ));
294            }
295
296            // Zip Slip: reject path traversal and absolute paths.
297            if name.contains("..") || name.starts_with('/') {
298                return Err(format!("suspicious path: {name}"));
299            }
300
301            let size = entry.size();
302            let compressed = entry.compressed_size();
303
304            if size > self.max_single_uncompressed {
305                return Err(format!(
306                    "entry '{name}' too large: {size} bytes (max {})",
307                    self.max_single_uncompressed
308                ));
309            }
310
311            if compressed > 0 && size / compressed > self.max_compression_ratio {
312                return Err(format!(
313                    "entry '{name}' compression ratio too high: {}x (max {}x)",
314                    size / compressed,
315                    self.max_compression_ratio
316                ));
317            }
318
319            total_uncompressed = total_uncompressed.saturating_add(size);
320            total_compressed = total_compressed.saturating_add(compressed);
321        }
322
323        if total_uncompressed > self.max_total_uncompressed {
324            return Err(format!(
325                "total uncompressed too large: {total_uncompressed} bytes (max {})",
326                self.max_total_uncompressed
327            ));
328        }
329
330        if total_compressed > 0
331            && total_uncompressed / total_compressed > self.max_compression_ratio
332        {
333            return Err(format!(
334                "overall compression ratio too high: {}x (max {}x)",
335                total_uncompressed / total_compressed,
336                self.max_compression_ratio
337            ));
338        }
339
340        Ok(())
341    }
342}
343
344// ---------------------------------------------------------------------------
345// SecurityPolicy
346// ---------------------------------------------------------------------------
347
348/// Combined security policy holding SSRF and ZIP limits guards.
349///
350/// Passed into the reader to enforce security checks at the entry point.
351///
352/// # Examples
353///
354/// ```
355/// use easydoc_reader::security::SecurityPolicy;
356///
357/// let policy = SecurityPolicy::new();
358/// assert!(policy.ssrf.check_url("https://example.com").is_ok());
359/// assert!(policy.ssrf.check_url("http://127.0.0.1").is_err());
360/// ```
361#[derive(Debug, Clone)]
362pub struct SecurityPolicy {
363    /// SSRF protection guard.
364    pub ssrf: SsrfGuard,
365    /// ZIP bomb / element explosion limits.
366    pub limits: PackageLimits,
367}
368
369impl Default for SecurityPolicy {
370    fn default() -> Self {
371        Self {
372            ssrf: SsrfGuard::new(),
373            limits: PackageLimits::new(),
374        }
375    }
376}
377
378impl SecurityPolicy {
379    /// Creates a policy with the default conservative settings.
380    ///
381    /// SSRF guard blocks private IPs and localhost; ZIP limits cap at
382    /// 100 MB total / 50 MB per entry / 100x compression ratio.
383    #[must_use]
384    pub fn new() -> Self {
385        Self::default()
386    }
387
388    /// Creates a permissive policy: only scheme enforcement for SSRF,
389    /// and no ZIP limits (validation will always pass).
390    ///
391    /// Useful for trusted input environments where only basic sanity
392    /// checks are desired.
393    #[must_use]
394    pub fn permissive() -> Self {
395        Self {
396            ssrf: SsrfGuard::permissive(),
397            // No limits: max everything out.
398            limits: PackageLimits {
399                max_total_uncompressed: u64::MAX,
400                max_single_uncompressed: u64::MAX,
401                max_compression_ratio: u64::MAX,
402                max_entries: usize::MAX,
403                max_filename_len: usize::MAX,
404            },
405        }
406    }
407}
408
409// ===========================================================================
410// Tests
411// ===========================================================================
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    // -----------------------------------------------------------------------
418    // SsrfGuard tests
419    // -----------------------------------------------------------------------
420
421    #[test]
422    fn check_url_blocks_localhost_literal() {
423        let guard = SsrfGuard::new();
424        let err = guard.check_url("http://localhost/admin").unwrap_err();
425        assert!(err.contains("blocked host"), "error: {err}");
426    }
427
428    #[test]
429    fn check_url_blocks_private_ip_10_x() {
430        let guard = SsrfGuard {
431            resolve_dns: false,
432            ..SsrfGuard::new()
433        };
434        let err = guard.check_url("http://10.0.0.1/secret").unwrap_err();
435        assert!(err.contains("blocked IPv4"), "error: {err}");
436    }
437
438    #[test]
439    fn check_url_blocks_private_ip_192_168() {
440        let guard = SsrfGuard {
441            resolve_dns: false,
442            ..SsrfGuard::new()
443        };
444        let err = guard.check_url("http://192.168.1.1/internal").unwrap_err();
445        assert!(err.contains("blocked IPv4"), "error: {err}");
446    }
447
448    #[test]
449    fn check_url_blocks_private_ip_172_16() {
450        let guard = SsrfGuard {
451            resolve_dns: false,
452            ..SsrfGuard::new()
453        };
454        let err = guard.check_url("http://172.16.0.1/api").unwrap_err();
455        assert!(err.contains("blocked IPv4"), "error: {err}");
456    }
457
458    #[test]
459    fn check_url_blocks_ipv6_loopback() {
460        let guard = SsrfGuard {
461            resolve_dns: false,
462            ..SsrfGuard::new()
463        };
464        let err = guard.check_url("http://[::1]/admin").unwrap_err();
465        assert!(
466            err.contains("blocked IPv6") || err.contains("blocked host"),
467            "error: {err}"
468        );
469    }
470
471    #[test]
472    fn check_url_blocks_link_local_169_254() {
473        let guard = SsrfGuard {
474            resolve_dns: false,
475            ..SsrfGuard::new()
476        };
477        let err = guard
478            .check_url("http://169.254.169.254/metadata")
479            .unwrap_err();
480        assert!(err.contains("blocked IPv4"), "error: {err}");
481    }
482
483    #[test]
484    fn check_url_rejects_ftp_scheme() {
485        let guard = SsrfGuard::new();
486        let err = guard.check_url("ftp://example.com/file.txt").unwrap_err();
487        assert!(err.contains("scheme"), "error: {err}");
488    }
489
490    #[test]
491    fn check_url_rejects_empty_host() {
492        let guard = SsrfGuard {
493            resolve_dns: false,
494            ..SsrfGuard::new()
495        };
496        let err = guard.check_url("http:///path").unwrap_err();
497        assert!(err.contains("empty host"), "error: {err}");
498    }
499
500    #[test]
501    fn check_url_rejects_missing_scheme() {
502        let guard = SsrfGuard::new();
503        let err = guard.check_url("example.com/page").unwrap_err();
504        assert!(err.contains("missing scheme"), "error: {err}");
505    }
506
507    #[test]
508    fn check_url_allows_https_external() {
509        let guard = SsrfGuard {
510            resolve_dns: false,
511            ..SsrfGuard::new()
512        };
513        guard
514            .check_url("https://example.com/page")
515            .expect("https://example.com should be allowed");
516    }
517
518    #[test]
519    fn check_url_allows_mailto() {
520        let guard = SsrfGuard::new();
521        guard
522            .check_url("mailto:user@example.com")
523            .expect("mailto: should be allowed");
524    }
525
526    #[test]
527    fn check_url_blocks_carrier_grade_nat() {
528        let guard = SsrfGuard {
529            resolve_dns: false,
530            ..SsrfGuard::new()
531        };
532        let err = guard.check_url("http://100.64.0.1/internal").unwrap_err();
533        assert!(err.contains("blocked IPv4"), "error: {err}");
534    }
535
536    #[test]
537    fn check_url_blocks_zero_network() {
538        let guard = SsrfGuard {
539            resolve_dns: false,
540            ..SsrfGuard::new()
541        };
542        let err = guard.check_url("http://0.0.0.0/admin").unwrap_err();
543        assert!(err.contains("blocked IPv4"), "error: {err}");
544    }
545
546    #[test]
547    fn permissive_guard_allows_private_ip() {
548        let guard = SsrfGuard::permissive();
549        guard
550            .check_url("http://192.168.1.1/api")
551            .expect("permissive guard should allow private IPs");
552    }
553
554    #[test]
555    fn permissive_guard_still_blocks_unknown_scheme() {
556        let guard = SsrfGuard::permissive();
557        let err = guard.check_url("ftp://example.com").unwrap_err();
558        assert!(err.contains("scheme"), "error: {err}");
559    }
560
561    #[test]
562    fn check_url_with_port() {
563        let guard = SsrfGuard {
564            resolve_dns: false,
565            ..SsrfGuard::new()
566        };
567        guard
568            .check_url("https://example.com:8080/api")
569            .expect("external host with port should be allowed");
570    }
571
572    // -----------------------------------------------------------------------
573    // PackageLimits tests
574    // -----------------------------------------------------------------------
575
576    /// Builds a minimal in-memory ZIP archive with the given entries.
577    fn build_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
578        use std::io::Write;
579        let mut buf = Vec::new();
580        {
581            let w = std::io::Cursor::new(&mut buf);
582            let mut zip = zip::ZipWriter::new(w);
583            let options = zip::write::SimpleFileOptions::default()
584                .compression_method(zip::CompressionMethod::Stored);
585            for (name, data) in entries {
586                zip.start_file(*name, options).unwrap();
587                zip.write_all(data).unwrap();
588            }
589            zip.finish().unwrap();
590        }
591        buf
592    }
593
594    #[test]
595    fn validate_archive_passes_normal() {
596        let data = build_zip(&[
597            ("word/document.xml", b"<document/>"),
598            ("word/styles.xml", b"<styles/>"),
599        ]);
600        let reader = std::io::Cursor::new(data);
601        let mut archive = zip::ZipArchive::new(reader).unwrap();
602        let limits = PackageLimits::new();
603        limits
604            .validate_archive(&mut archive)
605            .expect("normal archive should pass");
606    }
607
608    #[test]
609    fn validate_archive_rejects_too_many_entries() {
610        let mut entries: Vec<(&str, Vec<u8>)> = Vec::new();
611        for i in 0..50 {
612            entries.push((
613                // Leak a static-ish string for the test.
614                Box::leak(format!("file{i}.txt").into_boxed_str()) as &str,
615                b"hello".to_vec(),
616            ));
617        }
618        let entry_refs: Vec<(&str, &[u8])> =
619            entries.iter().map(|(n, d)| (*n, d.as_slice())).collect();
620        let data = build_zip(&entry_refs);
621        let reader = std::io::Cursor::new(data);
622        let mut archive = zip::ZipArchive::new(reader).unwrap();
623
624        let limits = PackageLimits {
625            max_entries: 10,
626            ..PackageLimits::new()
627        };
628        let err = limits.validate_archive(&mut archive).unwrap_err();
629        assert!(err.contains("too many entries"), "error: {err}");
630    }
631
632    #[test]
633    fn validate_archive_rejects_zip_slip() {
634        let data = build_zip(&[("word/../../../etc/passwd", b"root:x:0:0")]);
635        let reader = std::io::Cursor::new(data);
636        let mut archive = zip::ZipArchive::new(reader).unwrap();
637        let limits = PackageLimits::new();
638        let err = limits.validate_archive(&mut archive).unwrap_err();
639        assert!(err.contains("suspicious path"), "error: {err}");
640    }
641
642    #[test]
643    fn validate_archive_rejects_absolute_path() {
644        let data = build_zip(&[("/etc/passwd", b"root:x:0:0")]);
645        let reader = std::io::Cursor::new(data);
646        let mut archive = zip::ZipArchive::new(reader).unwrap();
647        let limits = PackageLimits::new();
648        let err = limits.validate_archive(&mut archive).unwrap_err();
649        assert!(err.contains("suspicious path"), "error: {err}");
650    }
651
652    #[test]
653    fn validate_archive_rejects_large_entry() {
654        // 200 bytes of data, limit set to 100 bytes.
655        let big_data = vec![0u8; 200];
656        let data = build_zip(&[("big.bin", big_data.as_slice())]);
657        let reader = std::io::Cursor::new(data);
658        let mut archive = zip::ZipArchive::new(reader).unwrap();
659
660        let limits = PackageLimits {
661            max_single_uncompressed: 100,
662            ..PackageLimits::new()
663        };
664        let err = limits.validate_archive(&mut archive).unwrap_err();
665        assert!(err.contains("too large"), "error: {err}");
666    }
667
668    #[test]
669    fn validate_archive_rejects_high_compression_ratio() {
670        // Create a ZIP entry with Deflated compression to get a real ratio.
671        use std::io::Write;
672        let big_data = vec![0u8; 100_000];
673        let mut buf = Vec::new();
674        {
675            let w = std::io::Cursor::new(&mut buf);
676            let mut zip = zip::ZipWriter::new(w);
677            let options = zip::write::SimpleFileOptions::default()
678                .compression_method(zip::CompressionMethod::Deflated)
679                .compression_level(Some(9));
680            zip.start_file("bomb.bin", options).unwrap();
681            zip.write_all(&big_data).unwrap();
682            zip.finish().unwrap();
683        }
684
685        let reader = std::io::Cursor::new(buf);
686        let mut archive = zip::ZipArchive::new(reader).unwrap();
687
688        // Set ratio limit to 2x -- highly compressible data will exceed this.
689        let limits = PackageLimits {
690            max_compression_ratio: 2,
691            ..PackageLimits::new()
692        };
693        let err = limits.validate_archive(&mut archive).unwrap_err();
694        assert!(err.contains("compression ratio too high"), "error: {err}");
695    }
696
697    #[test]
698    fn validate_archive_rejects_filename_too_long() {
699        let long_name = format!("{}.xml", "a".repeat(300));
700        let name_ref: &str = Box::leak(long_name.into_boxed_str());
701        let data = build_zip(&[(name_ref, b"<data/>")]);
702        let reader = std::io::Cursor::new(data);
703        let mut archive = zip::ZipArchive::new(reader).unwrap();
704
705        let limits = PackageLimits {
706            max_filename_len: 100,
707            ..PackageLimits::new()
708        };
709        let err = limits.validate_archive(&mut archive).unwrap_err();
710        assert!(err.contains("filename too long"), "error: {err}");
711    }
712
713    #[test]
714    fn validate_archive_rejects_total_too_large() {
715        let a = vec![0u8; 60];
716        let b = vec![0u8; 60];
717        let data = build_zip(&[("a.bin", a.as_slice()), ("b.bin", b.as_slice())]);
718        let reader = std::io::Cursor::new(data);
719        let mut archive = zip::ZipArchive::new(reader).unwrap();
720
721        let limits = PackageLimits {
722            max_total_uncompressed: 100,
723            ..PackageLimits::new()
724        };
725        let err = limits.validate_archive(&mut archive).unwrap_err();
726        assert!(err.contains("total uncompressed too large"), "error: {err}");
727    }
728
729    // -----------------------------------------------------------------------
730    // SecurityPolicy tests
731    // -----------------------------------------------------------------------
732
733    #[test]
734    fn security_policy_default_is_conservative() {
735        let policy = SecurityPolicy::new();
736        // SSRF blocks localhost
737        assert!(policy.ssrf.check_url("http://localhost/x").is_err());
738        // Limits are reasonable
739        assert_eq!(policy.limits.max_total_uncompressed, 100 * 1024 * 1024);
740    }
741
742    #[test]
743    fn security_policy_permissive_relaxes_limits() {
744        let policy = SecurityPolicy::permissive();
745        // SSRF permissive still blocks unknown schemes
746        assert!(policy.ssrf.check_url("ftp://x.com").is_err());
747        // But allows private IPs
748        assert!(policy.ssrf.check_url("http://10.0.0.1/x").is_ok());
749        // ZIP limits are maximized
750        assert_eq!(policy.limits.max_entries, usize::MAX);
751    }
752}