Skip to main content

cratefield_core/ports/
url_safety.rs

1//! The `UrlSafety` port (issue #857): whether a URL is safe to fetch,
2//! asked by capability, never by vendor (ADR 0002, ports and adapters) —
3//! the way [`TextModel`](crate::TextModel) is for completions. A module
4//! holds `Arc<dyn UrlSafety>` and never learns which provider answered.
5//!
6//! # The privacy contract
7//!
8//! **The checked URL is a secret.** It names who is about to fetch what:
9//! a password reset, an unsubscribe, a reporter's source. Every
10//! implementation MUST never log the checked URL and MUST never place it
11//! in an error string. [`UrlSafetyError`] carries no URL in any variant,
12//! and the one variant carrying provider text
13//! ([`UrlSafetyError::Provider`]) is scrubbed through
14//! [`crate::logging::scrub_text`] in `Display`, the way
15//! [`MailError`](crate::MailError)'s and [`PushError`](crate::PushError)'s
16//! are (issue #235). `Debug` still shows the raw string for tests; the
17//! logging formatters scrub `{:?}` output too.
18//!
19//! **Limits are checked locally, before any network call.** Every
20//! implementation MUST run [`check_url`] / [`check_batch`] on the input
21//! first, so an over-long URL or an over-size batch is refused with
22//! [`UrlSafetyError::LimitExceeded`] without being paid for, and every
23//! adapter refuses identically. The defaults
24//! ([`UrlSafetyLimits::default()`]) are the 2 048-byte URL every browser
25//! and provider accepts in a request line and Safe Browsing v4's
26//! 500-URL lookup batch.
27//!
28//! [`CachedUrlSafety`] decorates any implementation with a bounded,
29//! [`Clock`]-driven TTL cache that honours the provider's advised
30//! retention. Its lock is never held across an `await`.
31
32use std::collections::HashMap;
33use std::sync::Arc;
34use std::time::Duration;
35
36use async_trait::async_trait;
37use time::OffsetDateTime;
38
39use crate::Clock;
40
41/// The longest URL one check accepts: 2 048 bytes, the length every
42/// browser and every URL-safety provider accepts in a request line.
43pub const DEFAULT_MAX_URL_LEN: usize = 2048;
44
45/// The most URLs one batch accepts: Safe Browsing v4's documented
46/// `threatMatches` lookup ceiling.
47pub const DEFAULT_MAX_BATCH: usize = 500;
48
49/// How many URLs [`CachedUrlSafety`] holds at once.
50pub const DEFAULT_CACHE_CAPACITY: usize = 4096;
51
52/// How long [`CachedUrlSafety`] serves a verdict whose provider said
53/// nothing about retention ([`UrlVerdict::cache_ttl`] is `None`):
54/// five minutes.
55pub const DEFAULT_NEGATIVE_TTL: Duration = Duration::from_secs(300);
56
57/// What a provider says a URL does. The names map 1:1 onto Google Web
58/// Risk v1's and Safe Browsing v4's `threatType` values, so an adapter
59/// translates nothing.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61#[non_exhaustive]
62pub enum ThreatType {
63    /// A drive-by download or an exploit page.
64    Malware,
65    /// Deceptive: phishing and the rest of the social-engineering family.
66    SocialEngineering,
67    /// Software the user did not meaningfully agree to (adware and the
68    /// like).
69    UnwantedSoftware,
70    /// An app the provider rates harmful.
71    PotentiallyHarmfulApplication,
72}
73
74impl ThreatType {
75    /// The provider wire name, shared by Google Web Risk v1 and Safe
76    /// Browsing v4.
77    #[must_use]
78    pub const fn as_str(self) -> &'static str {
79        match self {
80            Self::Malware => "MALWARE",
81            Self::SocialEngineering => "SOCIAL_ENGINEERING",
82            Self::UnwantedSoftware => "UNWANTED_SOFTWARE",
83            Self::PotentiallyHarmfulApplication => "POTENTIALLY_HARMFUL_APPLICATION",
84        }
85    }
86}
87
88/// Which provider produced a verdict.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90#[non_exhaustive]
91pub enum UrlSafetySource {
92    /// Google Web Risk.
93    GoogleWebRisk,
94    /// Google Safe Browsing.
95    GoogleSafeBrowsing,
96    /// A decorator in front of the real provider (a cache rebuilding a
97    /// verdict, say); it names itself, never the provider behind it.
98    Cache,
99}
100
101impl UrlSafetySource {
102    /// The short name used in logs and health reports.
103    #[must_use]
104    pub const fn as_str(self) -> &'static str {
105        match self {
106            Self::GoogleWebRisk => "google-webrisk",
107            Self::GoogleSafeBrowsing => "google-safebrowsing",
108            Self::Cache => "cache",
109        }
110    }
111}
112
113/// One URL's safety verdict. An empty `threats` means known-clean —
114/// there is no separate "safe" flag to disagree with it.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct UrlVerdict {
117    /// The URL this verdict is for, verbatim as it was asked.
118    pub url: String,
119    /// Every threat the provider named; empty for a known-clean URL.
120    pub threats: Vec<ThreatType>,
121    /// Which provider produced the verdict.
122    pub source: UrlSafetySource,
123    /// When the provider answered.
124    pub checked_at: OffsetDateTime,
125    /// The provider-advised retention for this verdict; `None` when it
126    /// said nothing (a cache then applies its own negative TTL).
127    pub cache_ttl: Option<Duration>,
128    /// Whether a cache served this verdict instead of the provider.
129    pub cached: bool,
130}
131
132impl UrlVerdict {
133    /// Whether the provider named any threat.
134    #[must_use]
135    pub fn is_threat(&self) -> bool {
136        !self.threats.is_empty()
137    }
138}
139
140/// Which [`UrlSafetyLimits`] ceiling an ask crossed.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142#[non_exhaustive]
143pub enum UrlSafetyLimit {
144    /// The URL's byte length over [`UrlSafetyLimits::max_url_len`].
145    UrlLength,
146    /// The batch's size over [`UrlSafetyLimits::max_batch`].
147    BatchSize,
148}
149
150impl UrlSafetyLimit {
151    /// The name used in errors and logs.
152    #[must_use]
153    pub const fn name(self) -> &'static str {
154        match self {
155            Self::UrlLength => "url length",
156            Self::BatchSize => "batch size",
157        }
158    }
159}
160
161/// The local ceilings a [`UrlSafety`] adapter enforces before any network
162/// call.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub struct UrlSafetyLimits {
165    /// The longest URL accepted, in bytes.
166    pub max_url_len: usize,
167    /// The most URLs accepted in one [`UrlSafety::check_many`] call.
168    pub max_batch: usize,
169}
170
171impl Default for UrlSafetyLimits {
172    fn default() -> Self {
173        Self {
174            max_url_len: DEFAULT_MAX_URL_LEN,
175            max_batch: DEFAULT_MAX_BATCH,
176        }
177    }
178}
179
180/// URL-safety failures, mapped by the adapter from provider responses.
181///
182/// **No variant carries the checked URL.** It names the caller's next
183/// step, and an error string is one `format!` away from a log line. The
184/// one variant carrying provider text is sanitized in `Display`, the way
185/// [`MailError`](crate::MailError)'s and [`PushError`](crate::PushError)'s
186/// are (issue #235): `Display` runs it through
187/// [`crate::logging::scrub_text`], so every tracing field, problem detail
188/// and report line that renders the error gets the sanitized text.
189/// `Debug` still shows the raw string for tests.
190#[derive(Debug, Clone, PartialEq, Eq)]
191#[non_exhaustive]
192pub enum UrlSafetyError {
193    /// The input does not parse as a URL, or its scheme is not `http` or
194    /// `https`: nothing was sent anywhere. The offending text is
195    /// deliberately not carried.
196    InvalidUrl,
197    /// A local ceiling was crossed and the provider was never called.
198    LimitExceeded {
199        limit: UrlSafetyLimit,
200        actual: usize,
201        max: usize,
202    },
203    /// The provider refused the request (a `4xx`); not retryable without
204    /// a change. The status only — never the URL that drew it.
205    Rejected { status: u16 },
206    /// Retryable (a `5xx`, a `429`, a transport error); wait out
207    /// `retry_after` when the provider named one.
208    Transient { retry_after: Option<Duration> },
209    /// The provider failed on its side, in its own (scrubbed) words.
210    Provider(String),
211}
212
213impl std::fmt::Display for UrlSafetyError {
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        let scrub = crate::logging::scrub_text;
216        match self {
217            Self::InvalidUrl => f.write_str("not an http(s) URL; nothing was checked"),
218            Self::LimitExceeded { limit, actual, max } => {
219                write!(f, "over the {} ceiling: {actual} > {max}", limit.name())
220            }
221            Self::Rejected { status } => {
222                write!(
223                    f,
224                    "url safety provider rejected the request: status {status}"
225                )
226            }
227            Self::Transient { retry_after } => {
228                write!(
229                    f,
230                    "url safety call failed, retryable: retry after {retry_after:?}"
231                )
232            }
233            Self::Provider(message) => write!(f, "url safety provider failed: {}", scrub(message)),
234        }
235    }
236}
237
238impl std::error::Error for UrlSafetyError {}
239
240impl UrlSafetyError {
241    /// The provider-named back-off, where there was one.
242    #[must_use]
243    pub fn retry_after(&self) -> Option<Duration> {
244        match self {
245            Self::Transient { retry_after } => *retry_after,
246            _ => None,
247        }
248    }
249}
250
251/// Refuses a URL no provider should ever see, **before** any network
252/// call: one over [`UrlSafetyLimits::max_url_len`] bytes is
253/// [`UrlSafetyError::LimitExceeded`], and a string that does not parse as
254/// a URL or whose scheme is not `http` or `https` is
255/// [`UrlSafetyError::InvalidUrl`].
256///
257/// # Errors
258/// The refusals named above; the length check runs first.
259pub fn check_url(url: &str, limits: &UrlSafetyLimits) -> Result<(), UrlSafetyError> {
260    if url.len() > limits.max_url_len {
261        return Err(UrlSafetyError::LimitExceeded {
262            limit: UrlSafetyLimit::UrlLength,
263            actual: url.len(),
264            max: limits.max_url_len,
265        });
266    }
267    let parsed = url::Url::parse(url).map_err(|_| UrlSafetyError::InvalidUrl)?;
268    if parsed.scheme() != "http" && parsed.scheme() != "https" {
269        return Err(UrlSafetyError::InvalidUrl);
270    }
271    Ok(())
272}
273
274/// Refuses a batch the port's ceilings reject, **before** any network
275/// call: more than [`UrlSafetyLimits::max_batch`] URLs is
276/// [`UrlSafetyError::LimitExceeded`], and any one invalid URL is
277/// [`UrlSafetyError::InvalidUrl`]. An empty batch is fine — there is
278/// nothing in it to refuse.
279///
280/// # Errors
281/// The refusals named above; the batch size first, then the URLs in
282/// order.
283pub fn check_batch(urls: &[String], limits: &UrlSafetyLimits) -> Result<(), UrlSafetyError> {
284    if urls.len() > limits.max_batch {
285        return Err(UrlSafetyError::LimitExceeded {
286            limit: UrlSafetyLimit::BatchSize,
287            actual: urls.len(),
288            max: limits.max_batch,
289        });
290    }
291    for url in urls {
292        check_url(url, limits)?;
293    }
294    Ok(())
295}
296
297/// Asks whether URLs are safe to fetch, by capability and never by
298/// vendor. Every adapter MUST run [`check_url`] / [`check_batch`] at the
299/// top of `check` / `check_many`, before any network call, so a refusal
300/// costs nothing and every adapter refuses identically. The privacy
301/// contract in the module docs at the top of this file is binding: never log the checked
302/// URL, never carry it in an error string.
303#[async_trait]
304pub trait UrlSafety: Send + Sync {
305    /// Checks one URL. The returned verdict's `url` equals the input,
306    /// verbatim.
307    ///
308    /// # Errors
309    /// [`UrlSafetyError::InvalidUrl`] for a URL that does not parse or is
310    /// not http(s); [`UrlSafetyError::LimitExceeded`] for a local
311    /// ceiling; the refusal and failure variants otherwise.
312    async fn check(&self, url: &str) -> Result<UrlVerdict, UrlSafetyError>;
313
314    /// Checks many URLs; the results are in input order, one verdict per
315    /// input. The default runs [`check_batch`] locally, then
316    /// [`Self::check`] in a loop; a batch-friendly adapter overrides this
317    /// with one provider call.
318    ///
319    /// # Errors
320    /// As `check`; an over-size batch or any invalid URL refuses the
321    /// whole call before the provider is reached.
322    async fn check_many(&self, urls: &[String]) -> Result<Vec<UrlVerdict>, UrlSafetyError> {
323        check_batch(urls, &UrlSafetyLimits::default())?;
324        let mut verdicts = Vec::with_capacity(urls.len());
325        for url in urls {
326            verdicts.push(self.check(url).await?);
327        }
328        Ok(verdicts)
329    }
330}
331
332// The cache is the decorator's own state, shared across checks for an
333// entry's TTL — not request state, which is what ADR 0007 bans. Named,
334// so the allow sits on this one item instead of the whole file, as the
335// workspace `clippy.toml` requires and `cratefield-push-auth`'s token
336// cache already does. No lock is ever held across an `await`.
337#[allow(clippy::disallowed_types)]
338type Guarded<T> = std::sync::Mutex<T>;
339
340/// One cached verdict and when it stops being servable.
341struct Entry {
342    verdict: UrlVerdict,
343    expires_at: OffsetDateTime,
344}
345
346impl Entry {
347    /// Whether the entry is still servable at `now`.
348    fn fresh_at(&self, now: OffsetDateTime) -> bool {
349        now < self.expires_at
350    }
351}
352
353/// [`UrlSafety`] with a TTL cache in front of any implementation,
354/// honouring the provider's own durations: an entry lives for the
355/// verdict's [`UrlVerdict::cache_ttl`] where the provider gave one, and
356/// for [`Self::with_negative_ttl`] where it did not.
357///
358/// The map is **bounded** (the push-auth rule: no unbounded resource on
359/// an isolate) and the mutex is never held across an `await` — hits are
360/// served and misses staged in short critical sections around the one
361/// inner call.
362pub struct CachedUrlSafety {
363    inner: Arc<dyn UrlSafety>,
364    clock: Arc<dyn Clock>,
365    #[allow(clippy::disallowed_types)]
366    state: Guarded<HashMap<String, Entry>>,
367    capacity: usize,
368    negative_ttl: Duration,
369}
370
371impl CachedUrlSafety {
372    /// Wraps `inner`, holding at most [`DEFAULT_CACHE_CAPACITY`] URLs and
373    /// serving a provider-silent verdict for [`DEFAULT_NEGATIVE_TTL`].
374    #[must_use]
375    pub fn new(inner: Arc<dyn UrlSafety>, clock: Arc<dyn Clock>) -> Self {
376        Self {
377            inner,
378            clock,
379            state: Guarded::new(HashMap::new()),
380            capacity: DEFAULT_CACHE_CAPACITY,
381            negative_ttl: DEFAULT_NEGATIVE_TTL,
382        }
383    }
384
385    /// Caps the cache at `capacity` URLs; `0` falls back to
386    /// [`DEFAULT_CACHE_CAPACITY`] — a cache that holds nothing is not a
387    /// cache, and a caller who wants no caching simply does not decorate.
388    ///
389    /// Past the bound, the entry that expires first is evicted: it is the
390    /// one closest to needing a re-check anyway, so eviction costs one
391    /// extra provider call, never correctness.
392    #[must_use]
393    pub fn with_capacity(mut self, capacity: usize) -> Self {
394        self.capacity = if capacity == 0 {
395            DEFAULT_CACHE_CAPACITY
396        } else {
397            capacity
398        };
399        self
400    }
401
402    /// Overrides the TTL applied when a verdict's provider said nothing
403    /// ([`UrlVerdict::cache_ttl`] is `None`).
404    #[must_use]
405    pub fn with_negative_ttl(mut self, ttl: Duration) -> Self {
406        self.negative_ttl = ttl;
407        self
408    }
409
410    /// The fresh cached verdict for `url`, if there is one, stamped as
411    /// served-from-cache.
412    ///
413    /// # Panics
414    ///
415    /// If the cache mutex was poisoned by a panic in a previous check.
416    fn fresh(&self, url: &str) -> Option<UrlVerdict> {
417        let now = self.clock.now();
418        let state = self.state.lock().expect("url safety cache");
419        state
420            .get(url)
421            .filter(|entry| entry.fresh_at(now))
422            .map(Self::served)
423    }
424
425    /// A cached entry's verdict as it is served: the original with
426    /// `cached` stamped on.
427    fn served(entry: &Entry) -> UrlVerdict {
428        let mut verdict = entry.verdict.clone();
429        verdict.cached = true;
430        verdict
431    }
432
433    /// Writes `verdicts` into the map, making room before each insert.
434    ///
435    /// # Panics
436    ///
437    /// If the cache mutex was poisoned by a panic in a previous check.
438    fn store(&self, verdicts: &[UrlVerdict]) {
439        if verdicts.is_empty() {
440            return;
441        }
442        let now = self.clock.now();
443        let mut state = self.state.lock().expect("url safety cache");
444        for verdict in verdicts {
445            Self::insert(&mut state, verdict, now, self.capacity, self.negative_ttl);
446        }
447    }
448
449    /// Inserts one entry, making room first: expired entries go free
450    /// (they would not be served again regardless), then the
451    /// earliest-expiring entries go while the map is at capacity.
452    fn insert(
453        state: &mut HashMap<String, Entry>,
454        verdict: &UrlVerdict,
455        now: OffsetDateTime,
456        capacity: usize,
457        negative_ttl: Duration,
458    ) {
459        state.retain(|_, entry| entry.fresh_at(now));
460        while state.len() >= capacity {
461            let Some(oldest) = state
462                .iter()
463                .min_by_key(|(_, entry)| entry.expires_at)
464                .map(|(url, _)| url.clone())
465            else {
466                break;
467            };
468            state.remove(&oldest);
469        }
470        let ttl = verdict.cache_ttl.unwrap_or(negative_ttl);
471        let expires_at = verdict
472            .checked_at
473            .saturating_add(time::Duration::try_from(ttl).unwrap_or(time::Duration::MAX));
474        state.insert(
475            verdict.url.clone(),
476            Entry {
477                verdict: verdict.clone(),
478                expires_at,
479            },
480        );
481    }
482
483    /// Splits `urls` into fresh cached verdicts (in input position,
484    /// stamped `cached`) and the misses that must reach the inner
485    /// provider, under one short lock.
486    ///
487    /// # Panics
488    ///
489    /// If the cache mutex was poisoned by a panic in a previous check.
490    fn partition(&self, urls: &[String]) -> (Vec<Option<UrlVerdict>>, Vec<String>) {
491        let now = self.clock.now();
492        let state = self.state.lock().expect("url safety cache");
493        let mut slots = Vec::with_capacity(urls.len());
494        let mut misses = Vec::new();
495        for url in urls {
496            let hit = state
497                .get(url)
498                .filter(|entry| entry.fresh_at(now))
499                .map(Self::served);
500            if hit.is_none() {
501                misses.push(url.clone());
502            }
503            slots.push(hit);
504        }
505        drop(state);
506        (slots, misses)
507    }
508}
509
510#[async_trait]
511impl UrlSafety for CachedUrlSafety {
512    async fn check(&self, url: &str) -> Result<UrlVerdict, UrlSafetyError> {
513        if let Some(verdict) = self.fresh(url) {
514            return Ok(verdict);
515        }
516        // The lock is already dropped: no lock is held across the await.
517        let verdict = self.inner.check(url).await?;
518        self.store(std::slice::from_ref(&verdict));
519        Ok(verdict)
520    }
521
522    async fn check_many(&self, urls: &[String]) -> Result<Vec<UrlVerdict>, UrlSafetyError> {
523        check_batch(urls, &UrlSafetyLimits::default())?;
524        let (mut slots, misses) = self.partition(urls);
525        if !misses.is_empty() {
526            // ONE inner batch call covers every miss; the fetched entries
527            // go back into the cache before the results are merged into
528            // input order.
529            let fetched = self.inner.check_many(&misses).await?;
530            self.store(&fetched);
531            let mut fetched = fetched.into_iter();
532            for slot in &mut slots {
533                if slot.is_none() {
534                    *slot = fetched.next();
535                }
536            }
537        }
538        // The inner port answers one verdict per input, so every slot is
539        // filled; `flatten` keeps the merge total without a panic on an
540        // inner that broke its own contract.
541        Ok(slots.into_iter().flatten().collect())
542    }
543}
544
545impl std::fmt::Debug for CachedUrlSafety {
546    /// Never prints a URL: the map's contents are the checked URLs.
547    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
548        let entries = self
549            .state
550            .lock()
551            .map_or(0, |state: std::sync::MutexGuard<'_, _>| state.len());
552        f.debug_struct("CachedUrlSafety")
553            .field("capacity", &self.capacity)
554            .field("negative_ttl", &self.negative_ttl)
555            .field("entries", &entries)
556            .finish_non_exhaustive()
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use std::sync::atomic::{AtomicBool, AtomicI64, AtomicUsize, Ordering};
564
565    fn limits() -> UrlSafetyLimits {
566        UrlSafetyLimits {
567            max_url_len: 32,
568            max_batch: 2,
569        }
570    }
571
572    #[test]
573    fn threat_types_and_sources_name_themselves() {
574        assert_eq!(ThreatType::Malware.as_str(), "MALWARE");
575        assert_eq!(ThreatType::SocialEngineering.as_str(), "SOCIAL_ENGINEERING");
576        assert_eq!(ThreatType::UnwantedSoftware.as_str(), "UNWANTED_SOFTWARE");
577        assert_eq!(
578            ThreatType::PotentiallyHarmfulApplication.as_str(),
579            "POTENTIALLY_HARMFUL_APPLICATION"
580        );
581        assert_eq!(UrlSafetySource::GoogleWebRisk.as_str(), "google-webrisk");
582        assert_eq!(
583            UrlSafetySource::GoogleSafeBrowsing.as_str(),
584            "google-safebrowsing"
585        );
586        assert_eq!(UrlSafetySource::Cache.as_str(), "cache");
587    }
588
589    #[test]
590    fn check_url_accepts_http_and_https_and_refuses_the_rest() {
591        let limits = UrlSafetyLimits::default();
592        assert_eq!(check_url("https://example.com/", &limits), Ok(()));
593        assert_eq!(check_url("http://example.com/a?b=c", &limits), Ok(()));
594        assert_eq!(
595            check_url("ftp://example.com/file", &limits),
596            Err(UrlSafetyError::InvalidUrl)
597        );
598        // Schemeless: `url` cannot find a base to resolve against.
599        assert_eq!(
600            check_url("example.com/path", &limits),
601            Err(UrlSafetyError::InvalidUrl)
602        );
603        assert_eq!(check_url("", &limits), Err(UrlSafetyError::InvalidUrl));
604        assert_eq!(
605            check_url("mailto:someone@example.com", &limits),
606            Err(UrlSafetyError::InvalidUrl)
607        );
608    }
609
610    #[test]
611    fn check_url_refuses_an_over_long_url_as_a_limit() {
612        let limits = limits();
613        // "https://example.com/" is 20 bytes, so 12 fillers land exactly
614        // on the ceiling and 13 cross it.
615        let fits = format!("https://example.com/{}", "a".repeat(12));
616        assert_eq!(fits.len(), 32);
617        assert_eq!(check_url(&fits, &limits), Ok(()));
618        let long = format!("https://example.com/{}", "a".repeat(13));
619        assert_eq!(
620            check_url(&long, &limits),
621            Err(UrlSafetyError::LimitExceeded {
622                limit: UrlSafetyLimit::UrlLength,
623                actual: 33,
624                max: 32,
625            })
626        );
627    }
628
629    #[test]
630    fn check_batch_bounds_the_size_and_still_checks_each_url() {
631        let limits = limits();
632        assert_eq!(check_batch(&[], &limits), Ok(()), "an empty batch is fine");
633        let ok = vec![
634            "https://a.example".to_owned(),
635            "https://b.example".to_owned(),
636        ];
637        assert_eq!(check_batch(&ok, &limits), Ok(()));
638
639        let oversized = vec![
640            "https://a.example".to_owned(),
641            "https://b.example".to_owned(),
642            "https://c.example".to_owned(),
643        ];
644        assert_eq!(
645            check_batch(&oversized, &limits),
646            Err(UrlSafetyError::LimitExceeded {
647                limit: UrlSafetyLimit::BatchSize,
648                actual: 3,
649                max: 2,
650            })
651        );
652
653        // A batch within its size ceiling still refuses an invalid URL.
654        let one_bad = vec!["https://a.example".to_owned(), "ftp://b.example".to_owned()];
655        assert_eq!(
656            check_batch(&one_bad, &limits),
657            Err(UrlSafetyError::InvalidUrl)
658        );
659    }
660
661    #[test]
662    fn provider_text_is_scrubbed_in_display_but_not_debug() {
663        let error =
664            UrlSafetyError::Provider("400 for https://api.example.test?token=secret".to_owned());
665        let text = error.to_string();
666        assert!(!text.contains("secret"), "{text}");
667        assert!(format!("{error:?}").contains("secret"));
668        // The typed variants carry numbers and fixed text only — never
669        // the URL that drew the rejection.
670        assert_eq!(
671            UrlSafetyError::Rejected { status: 429 }.to_string(),
672            "url safety provider rejected the request: status 429"
673        );
674        assert_eq!(
675            UrlSafetyError::InvalidUrl.to_string(),
676            "not an http(s) URL; nothing was checked"
677        );
678    }
679
680    #[test]
681    fn transient_carries_an_optional_retry_after() {
682        let throttled = UrlSafetyError::Transient {
683            retry_after: Some(Duration::from_secs(30)),
684        };
685        assert_eq!(throttled.retry_after(), Some(Duration::from_secs(30)));
686        assert_eq!(
687            UrlSafetyError::Transient { retry_after: None }.retry_after(),
688            None
689        );
690        assert_eq!(UrlSafetyError::InvalidUrl.retry_after(), None);
691    }
692
693    /// A fake clock the test advances by hand.
694    struct FakeClock(AtomicI64);
695
696    impl FakeClock {
697        fn at(secs: i64) -> Arc<Self> {
698            Arc::new(Self(AtomicI64::new(secs)))
699        }
700
701        fn advance(&self, secs: i64) {
702            self.0.fetch_add(secs, Ordering::Relaxed);
703        }
704    }
705
706    impl Clock for FakeClock {
707        fn now(&self) -> OffsetDateTime {
708            OffsetDateTime::from_unix_timestamp(self.0.load(Ordering::Relaxed)).expect("in range")
709        }
710    }
711
712    /// An inner provider that counts its calls, stamps verdicts from the
713    /// same clock the cache sees, and fails on demand. `checks` counts
714    /// single checks, `batches` batch calls; neither feeds the other.
715    struct FakeInner {
716        clock: Arc<FakeClock>,
717        checks: AtomicUsize,
718        batches: AtomicUsize,
719        fail: AtomicBool,
720        /// The retention handed out with every verdict; `None` exercises
721        /// the negative-TTL path.
722        ttl: Option<Duration>,
723    }
724
725    impl FakeInner {
726        fn checks(&self) -> usize {
727            self.checks.load(Ordering::Relaxed)
728        }
729
730        fn batches(&self) -> usize {
731            self.batches.load(Ordering::Relaxed)
732        }
733
734        fn verdict(&self, url: &str) -> UrlVerdict {
735            let threats = if url.contains("malware") {
736                vec![ThreatType::Malware]
737            } else if url.contains("phishing") {
738                vec![ThreatType::SocialEngineering]
739            } else {
740                Vec::new()
741            };
742            UrlVerdict {
743                url: url.to_owned(),
744                threats,
745                source: UrlSafetySource::GoogleWebRisk,
746                checked_at: self.clock.now(),
747                cache_ttl: self.ttl,
748                cached: false,
749            }
750        }
751    }
752
753    #[async_trait]
754    impl UrlSafety for FakeInner {
755        async fn check(&self, url: &str) -> Result<UrlVerdict, UrlSafetyError> {
756            self.checks.fetch_add(1, Ordering::Relaxed);
757            if self.fail.load(Ordering::Relaxed) {
758                return Err(UrlSafetyError::Provider(format!("boom for {url}")));
759            }
760            Ok(self.verdict(url))
761        }
762
763        async fn check_many(&self, urls: &[String]) -> Result<Vec<UrlVerdict>, UrlSafetyError> {
764            self.batches.fetch_add(1, Ordering::Relaxed);
765            let mut verdicts = Vec::with_capacity(urls.len());
766            for url in urls {
767                if self.fail.load(Ordering::Relaxed) {
768                    return Err(UrlSafetyError::Provider(format!("boom for {url}")));
769                }
770                verdicts.push(self.verdict(url));
771            }
772            Ok(verdicts)
773        }
774    }
775
776    type Parts = (Arc<FakeClock>, Arc<FakeInner>, CachedUrlSafety);
777
778    /// A cache over a counting inner whose verdicts carry `ttl`.
779    fn cache_with(ttl: Option<Duration>, fail: bool) -> Parts {
780        let clock = FakeClock::at(1_000);
781        let inner = Arc::new(FakeInner {
782            clock: clock.clone(),
783            checks: AtomicUsize::new(0),
784            batches: AtomicUsize::new(0),
785            fail: AtomicBool::new(fail),
786            ttl,
787        });
788        let cache = CachedUrlSafety::new(inner.clone(), clock.clone());
789        (clock, inner, cache)
790    }
791
792    #[test]
793    fn a_hit_within_the_ttl_serves_the_cache_and_a_miss_goes_to_the_provider() {
794        let (clock, inner, cache) = cache_with(Some(Duration::from_secs(60)), false);
795
796        let url = "https://example.test/page";
797        let first = pollster::block_on(cache.check(url)).expect("checked");
798        assert_eq!(inner.checks(), 1);
799        assert!(!first.cached);
800        assert_eq!(
801            first.url, url,
802            "the verdict's url equals the input, verbatim"
803        );
804        assert!(!first.is_threat());
805
806        // Within the TTL: no provider call, same threats and stamp,
807        // `cached` on.
808        clock.advance(59);
809        let second = pollster::block_on(cache.check(url)).expect("checked");
810        assert_eq!(inner.checks(), 1, "served from the cache");
811        assert!(second.cached);
812        assert_eq!(second.threats, first.threats);
813        assert_eq!(second.checked_at, first.checked_at);
814
815        // Past the TTL, the provider is asked again.
816        clock.advance(2); // 61 s after the check
817        pollster::block_on(cache.check(url)).expect("checked");
818        assert_eq!(inner.checks(), 2, "past the TTL the provider answers again");
819    }
820
821    #[test]
822    fn a_verdict_without_a_provider_ttl_uses_the_negative_ttl() {
823        let (clock, inner, cache) = cache_with(None, false);
824        let cache = cache.with_negative_ttl(Duration::from_secs(30));
825
826        pollster::block_on(cache.check("https://example.test")).expect("checked");
827        clock.advance(29);
828        pollster::block_on(cache.check("https://example.test")).expect("checked");
829        assert_eq!(inner.checks(), 1, "still inside the negative TTL");
830
831        clock.advance(2); // 31 s after the check
832        pollster::block_on(cache.check("https://example.test")).expect("checked");
833        assert_eq!(inner.checks(), 2, "past the negative TTL");
834    }
835
836    #[test]
837    fn check_many_merges_in_input_order_with_one_batch_call_for_the_misses() {
838        let (_, inner, cache) = cache_with(Some(Duration::from_secs(300)), false);
839
840        // The plain URL is warm before the batch; the two named by
841        // threat are cold, so the batch has work to do.
842        pollster::block_on(cache.check("https://a.example")).expect("warmed");
843        assert_eq!(inner.checks(), 1);
844
845        let urls = vec![
846            "https://malware.example".to_owned(),
847            "https://a.example".to_owned(),
848            "https://phishing.example".to_owned(),
849        ];
850        let verdicts = pollster::block_on(cache.check_many(&urls)).expect("checked");
851        assert_eq!(verdicts.len(), urls.len(), "one verdict per input");
852        assert_eq!(inner.batches(), 1, "one batch call covered every miss");
853        assert_eq!(inner.checks(), 1, "the warm URL was not re-checked");
854        assert_eq!(verdicts[0].url, urls[0]);
855        assert_eq!(verdicts[0].threats, vec![ThreatType::Malware]);
856        assert!(!verdicts[0].cached);
857        assert_eq!(verdicts[1].url, urls[1], "the hit kept its input position");
858        assert!(verdicts[1].cached);
859        assert_eq!(verdicts[2].url, urls[2]);
860        assert_eq!(verdicts[2].threats, vec![ThreatType::SocialEngineering]);
861        assert!(!verdicts[2].cached);
862
863        // The fetched entries were stored: the same ask now hits in full.
864        let again = pollster::block_on(cache.check_many(&urls)).expect("checked");
865        assert_eq!(inner.batches(), 1, "nothing new reached the provider");
866        assert!(again.iter().all(|verdict| verdict.cached));
867    }
868
869    #[test]
870    fn the_cache_checks_limits_before_the_provider() {
871        let (_, inner, cache) = cache_with(Some(Duration::from_secs(60)), false);
872        let urls = (0..501)
873            .map(|n| format!("https://{n}.example"))
874            .collect::<Vec<_>>();
875        let error = pollster::block_on(cache.check_many(&urls)).expect_err("over the ceiling");
876        assert!(matches!(
877            error,
878            UrlSafetyError::LimitExceeded {
879                limit: UrlSafetyLimit::BatchSize,
880                ..
881            }
882        ));
883        assert_eq!(inner.batches(), 0, "the provider was never asked");
884    }
885
886    #[test]
887    fn a_full_cache_evicts_the_earliest_expiring_entry() {
888        let (_, inner, cache) = cache_with(Some(Duration::from_secs(60)), false);
889        let cache = cache.with_capacity(1);
890
891        pollster::block_on(cache.check("https://first.example")).expect("checked");
892        pollster::block_on(cache.check("https://second.example")).expect("checked");
893        assert_eq!(inner.checks(), 2);
894        // The first entry was evicted to make room, so re-checking it
895        // goes back to the provider...
896        pollster::block_on(cache.check("https://first.example")).expect("checked");
897        assert_eq!(inner.checks(), 3);
898        // ...and that re-check evicted the second, which is now a miss
899        // too. Capacity one holds, whatever the order of asks.
900        pollster::block_on(cache.check("https://second.example")).expect("checked");
901        assert_eq!(inner.checks(), 4);
902    }
903
904    #[test]
905    fn an_error_propagates_and_is_never_cached() {
906        let (_, inner, cache) = cache_with(Some(Duration::from_secs(60)), true);
907
908        let error = pollster::block_on(cache.check("https://example.test")).expect_err("failed");
909        assert!(matches!(error, UrlSafetyError::Provider(_)));
910        // Not cached: the next ask reaches the provider again.
911        pollster::block_on(cache.check("https://example.test")).expect_err("failed again");
912        assert_eq!(inner.checks(), 2, "a failed check must not be cached");
913    }
914}