Skip to main content

mbr_core/
url_path.rs

1//! URL path primitives shared by the link pipeline.
2//!
3//! Two concerns live here because every consumer needs both and duplicating
4//! either produces silent, platform- or scheme-specific breakage:
5//!
6//! 1. [`path_to_url`] — conversion of filesystem paths into URL paths. URL
7//!    paths are always `/`-separated, but `std::path` uses the platform
8//!    separator — `\` on Windows. Stringifying a `Path` with
9//!    `to_string_lossy()`, `to_str()` or `display()` and splicing the result
10//!    into a URL therefore produces `/docs\guide/` on Windows. Worse,
11//!    downstream logic that splits on `/` (index-file collapsing, extension
12//!    stripping, cache-key matching) then silently stops matching.
13//! 2. [`is_external_url`] — the single answer to "does this URL leave the
14//!    site?", used by link transformation, link tracking and link validation.
15//!
16//! It also holds [`srcset_url_spans`] / [`rewrite_srcset`], the one reading of
17//! an HTML `srcset` value, shared by the renderer's raw-HTML URL rewrite and
18//! QuickLook's attachment collector so the two cannot disagree about which
19//! bytes of a `srcset` are a URL.
20
21use std::borrow::Cow;
22use std::ops::Range;
23use std::path::{Component, Path};
24
25/// Converts a **relative** filesystem path into a `/`-separated URL path body.
26///
27/// The result has no leading or trailing slash, so callers stay in control of
28/// their own delimiters: `docs/guide.md`. An empty or root-only path yields an
29/// empty string.
30///
31/// Component handling:
32/// - `Normal` components are joined with `/`.
33/// - `ParentDir` (`..`) is preserved, because [`pathdiff::diff_paths`] can
34///   legitimately produce upward traversals that the caller depends on.
35/// - `CurDir` (`.`), `RootDir` (`/`) and `Prefix` (`C:`) are dropped: a URL
36///   path is always rooted at the site root, so they carry no meaning.
37///
38/// Non-UTF-8 components are lossily converted, matching the previous behavior
39/// of the call sites this replaced.
40///
41/// ```
42/// use std::path::Path;
43/// use mbr_core::url_path::path_to_url;
44///
45/// // Built with `join` so the separator is whatever the host uses natively;
46/// // the output is `/`-separated on every platform.
47/// let path = Path::new("docs").join("guide.md");
48/// assert_eq!(path_to_url(&path), "docs/guide.md");
49/// ```
50pub fn path_to_url(relative: &Path) -> String {
51    let mut url = String::new();
52
53    for component in relative.components() {
54        let piece: Cow<'_, str> = match component {
55            Component::Normal(name) => name.to_string_lossy(),
56            Component::ParentDir => Cow::Borrowed(".."),
57            Component::Prefix(_) | Component::RootDir | Component::CurDir => continue,
58        };
59
60        if !url.is_empty() {
61            url.push('/');
62        }
63        url.push_str(&piece);
64    }
65
66    url
67}
68
69/// Returns `true` when `url` addresses something outside this site.
70///
71/// This is the one place that answers the question. Link transformation
72/// ([`crate::link_transform::transform_link`]), link tracking
73/// ([`crate::link_index::is_internal_link`]) and link validation
74/// ([`crate::page_errors`]) all call it, so an href can never be rewritten as
75/// a relative path by one and reported as a broken internal link by another.
76///
77/// A URL is external when it is either:
78/// - **protocol-relative** (`//cdn.example.com/x.js`), which inherits the
79///   page's scheme and leaves the site without naming one; or
80/// - **scheme-qualified** — it starts with an RFC 3986 scheme followed by
81///   `:` (`ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"`).
82///
83/// Testing the *shape* rather than an allowlist of known schemes is
84/// deliberate: enumerating schemes is what made `ftp://`, `ftps://`,
85/// `magnet:`, `sms:`, `callto:` and `blob:` get mangled into relative paths
86/// and reported as broken internal links, one scheme list at a time.
87///
88/// Everything else is site-relative and therefore internal: absolute paths
89/// (`/docs/`), relative paths (`guide.md`), fragment-only (`#top`) and
90/// query-only (`?q=1`) references.
91///
92/// Two shapes are intentionally *not* read as schemes:
93/// - **Windows drive letters** (`C:/notes/x.md`). RFC 3986 permits one-letter
94///   schemes but none exist in practice, so a single letter before the colon
95///   is a drive, matching what every URL parser does.
96/// - **A colon in a later path segment** (`docs/a:b.md`), because `/`, `?`
97///   and `#` are not legal scheme characters and end the scan. A colon in the
98///   *first* segment (`Notes:2024.md`) does look like a scheme; RFC 3986
99///   requires such a reference to be written `./Notes:2024.md`, which this
100///   function correctly reports as internal.
101///
102/// ```
103/// use mbr_core::url_path::is_external_url;
104///
105/// assert!(is_external_url("magnet:?xt=urn:btih:abc"));
106/// assert!(is_external_url("//cdn.example.com/x.js"));
107/// assert!(!is_external_url("docs/guide.md"));
108/// assert!(!is_external_url("C:/notes/guide.md"));
109/// ```
110pub fn is_external_url(url: &str) -> bool {
111    // Protocol-relative first: it starts with `/` but is not site-relative.
112    if url.starts_with("//") {
113        return true;
114    }
115
116    // Absolute site paths, fragment-only and query-only references can never
117    // carry a scheme, so stop before scanning for a colon.
118    if url.starts_with(['/', '#', '?']) {
119        return false;
120    }
121
122    url_scheme(url).is_some()
123}
124
125/// Returns `true` when `url` addresses one of mbr's own `?path=`-driven
126/// viewer routes (`/.mbr/videos/?path=…`, and the `pdfs` / `audio` / `images`
127/// siblings), in any spelling.
128///
129/// Those routes are addressed by their query, not by a file. A static build
130/// does write `.mbr/videos/index.html` and friends, so the canonical spelling
131/// happens to land on a real file — but the slashless one a hand author writes
132/// (`/.mbr/videos?path=…`) lands on the directory, and a file-based checker
133/// calls it a missing trailing slash although every static host redirects it
134/// and the server routes it directly. The server's `errors.json` checker never
135/// judges them (it skips all of `/.mbr/`, see `page_errors`); this is the
136/// narrower rule the static build's checker applies so the two agree on them.
137///
138/// The match is on a `.mbr` **path segment** rather than a `/.mbr/` prefix
139/// because the pipeline emits three spellings of the same link: `/.mbr/videos/`
140/// (server mode, site-absolute), `.mbr/videos/` (build, root page) and
141/// `../.mbr/videos/` (build, nested page). Requiring the `path=` parameter
142/// keeps it narrow: a genuinely broken `/.mbr/whatever/` link carrying no such
143/// query is still reported.
144///
145/// ```
146/// use mbr_core::url_path::is_mbr_query_route;
147///
148/// assert!(is_mbr_query_route("/.mbr/videos/?path=%2Fvideos%2Fdemo.mp4"));
149/// assert!(is_mbr_query_route("../.mbr/pdfs?path=/docs/report.pdf"));
150/// assert!(!is_mbr_query_route("/.mbr/videos/"));
151/// ```
152pub fn is_mbr_query_route(url: &str) -> bool {
153    // Fragment first, then query: `#` closes a URI (RFC 3986 §3.5), so a `?`
154    // to its right is fragment text and never a query.
155    let before_fragment = url.split('#').next().unwrap_or_default();
156
157    before_fragment
158        .split_once('?')
159        .is_some_and(|(path, query)| {
160            path.split('/').any(|segment| segment == ".mbr")
161                && query
162                    .split('&')
163                    .any(|param| param.split_once('=').is_some_and(|(key, _)| key == "path"))
164        })
165}
166
167/// Returns the RFC 3986 scheme of `url` (without the trailing `:`), if any.
168///
169/// The scheme grammar excludes `/`, `?` and `#`, so a colon that appears after
170/// one of those (`docs/a:b.md`) fails the character check and yields `None`
171/// without a separate delimiter scan.
172///
173/// Public because the `mbr` binary's `external_open` needs the *name* of the
174/// scheme, not just whether one is present: the GUI navigation policy refuses
175/// to hand `javascript:`, `vbscript:` and `data:` to the operating system. It
176/// reuses this scan so there is still one definition of what a scheme looks
177/// like — the whole point of this module.
178pub fn url_scheme(url: &str) -> Option<&str> {
179    let colon = url.find(':')?;
180    let scheme = &url[..colon];
181
182    // Single letter before the colon is a Windows drive, not a scheme.
183    if scheme.len() < 2 {
184        return None;
185    }
186
187    let mut chars = scheme.chars();
188    let starts_with_alpha = chars.next().is_some_and(|c| c.is_ascii_alphabetic());
189    let rest_is_scheme_chars =
190        chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
191
192    (starts_with_alpha && rest_is_scheme_chars).then_some(scheme)
193}
194
195/// HTML's "ASCII whitespace": space, tab, LF, FF, CR.
196fn is_html_whitespace(byte: u8) -> bool {
197    matches!(byte, b' ' | b'\t' | b'\n' | b'\x0C' | b'\r')
198}
199
200/// The byte ranges of the candidate URLs in an HTML `srcset` value, in order.
201///
202/// A simplified reading of the HTML "parse a srcset attribute" algorithm:
203/// candidates are separated by commas; each is optional whitespace, a URL (a
204/// run of non-whitespace), then an optional descriptor (`2x`, `640w`) running
205/// to the next comma outside parentheses. Leading commas are skipped, and
206/// commas that end the URL's run (`a.png, b.png 2x` or `a.png,`) are the
207/// separator, not part of the URL. Descriptors are not validated: only the
208/// URLs are of interest, and everything else is kept byte for byte by
209/// [`rewrite_srcset`].
210///
211/// Not supported: URLs containing commas. A comma *inside* the run
212/// (`a,b.png`) stays in the URL, as the spec reads it, and the whole run is
213/// handed on as one URL; one at the run's end cannot be told from a
214/// separator. Neither occurs in a relative file path anyone writes.
215///
216/// Every range is non-empty, starts and ends on an ASCII byte boundary (so
217/// slicing is always valid), holds no whitespace, and neither starts nor ends
218/// with a comma.
219pub fn srcset_url_spans(srcset: &str) -> Vec<Range<usize>> {
220    let bytes = srcset.as_bytes();
221    let mut spans = Vec::new();
222    let mut pos = 0;
223    while pos < bytes.len() {
224        // Separators: whitespace and (stray or leading) commas.
225        while pos < bytes.len() && (is_html_whitespace(bytes[pos]) || bytes[pos] == b',') {
226            pos += 1;
227        }
228        if pos == bytes.len() {
229            break;
230        }
231
232        // The URL: a run of non-whitespace, minus any commas ending it.
233        let start = pos;
234        while pos < bytes.len() && !is_html_whitespace(bytes[pos]) {
235            pos += 1;
236        }
237        let end = start + srcset[start..pos].trim_end_matches(',').len();
238        // `bytes[start]` is neither whitespace nor a comma, so `end > start`.
239        spans.push(start..end);
240        if end < pos {
241            // A trailing comma ended this candidate: it has no descriptor.
242            continue;
243        }
244
245        // The descriptor: up to the next comma outside parentheses.
246        let mut depth = 0usize;
247        while pos < bytes.len() {
248            match bytes[pos] {
249                b'(' => depth += 1,
250                b')' => depth = depth.saturating_sub(1),
251                b',' if depth == 0 => break,
252                _ => {}
253            }
254            pos += 1;
255        }
256    }
257    spans
258}
259
260/// `srcset` with each candidate URL replaced by what `rewrite_url` returns
261/// for it; `None` keeps that URL as authored.
262///
263/// Only the URLs (see [`srcset_url_spans`]) change: descriptors, separators
264/// and whitespace, newlines included, are kept byte for byte. When
265/// `rewrite_url` returns `None` for every candidate the value is returned
266/// borrowed, so callers can tell "nothing to do" without comparing strings.
267/// Any `Some`, even one equal to the URL, rebuilds the value.
268///
269/// ```
270/// use mbr_core::url_path::rewrite_srcset;
271///
272/// let out = rewrite_srcset("a.png, b.png 2x", |url| (url == "b.png").then(|| "../b.png".into()));
273/// assert_eq!(out, "a.png, ../b.png 2x");
274/// ```
275pub fn rewrite_srcset<F>(srcset: &str, mut rewrite_url: F) -> Cow<'_, str>
276where
277    F: FnMut(&str) -> Option<String>,
278{
279    let mut rewritten: Option<String> = None;
280    let mut copied_to = 0;
281    for span in srcset_url_spans(srcset) {
282        let Some(replacement) = rewrite_url(&srcset[span.clone()]) else {
283            continue;
284        };
285        let out = rewritten.get_or_insert_with(|| String::with_capacity(srcset.len() + 16));
286        out.push_str(&srcset[copied_to..span.start]);
287        out.push_str(&replacement);
288        copied_to = span.end;
289    }
290    match rewritten {
291        Some(mut out) => {
292            out.push_str(&srcset[copied_to..]);
293            Cow::Owned(out)
294        }
295        None => Cow::Borrowed(srcset),
296    }
297}
298
299/// Serde helper: serializes a **relative** path as a `/`-separated string.
300///
301/// Use via `#[serde(serialize_with = "crate::url_path::serialize_as_url")]` on
302/// any `Path`/`PathBuf` field that crosses into JSON consumed by the frontend,
303/// so the wire format stays platform independent even though the in-memory
304/// value uses native separators.
305pub fn serialize_as_url<S>(path: &Path, serializer: S) -> Result<S::Ok, S::Error>
306where
307    S: serde::Serializer,
308{
309    serializer.serialize_str(&path_to_url(path))
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use std::path::PathBuf;
316
317    /// Builds a path from components using the platform separator, so these
318    /// tests exercise `\` on Windows and `/` on Unix from identical source.
319    fn native(parts: &[&str]) -> PathBuf {
320        parts.iter().collect()
321    }
322
323    #[test]
324    fn test_empty_path_is_empty_url() {
325        assert_eq!(path_to_url(Path::new("")), "");
326    }
327
328    #[test]
329    fn test_single_component() {
330        assert_eq!(path_to_url(Path::new("README.md")), "README.md");
331    }
332
333    #[test]
334    fn test_nested_path_uses_forward_slashes() {
335        // The core regression guard: a natively-joined path must come back
336        // `/`-separated regardless of host platform.
337        let path = native(&["docs", "guide", "index.md"]);
338        let url = path_to_url(&path);
339
340        assert_eq!(url, "docs/guide/index.md");
341        assert!(
342            !url.contains('\\'),
343            "URL must never contain a backslash, got {url}"
344        );
345    }
346
347    #[test]
348    fn test_deeply_nested_path() {
349        let path = native(&["a", "b", "c", "d", "e", "f.png"]);
350        assert_eq!(path_to_url(&path), "a/b/c/d/e/f.png");
351    }
352
353    #[test]
354    fn test_parent_dir_is_preserved() {
355        // pathdiff can legitimately produce `..`; dropping it would silently
356        // change which file a URL refers to.
357        let path = native(&["..", "..", "images", "photo.png"]);
358        assert_eq!(path_to_url(&path), "../../images/photo.png");
359    }
360
361    #[test]
362    fn test_current_dir_is_dropped() {
363        assert_eq!(path_to_url(Path::new(".")), "");
364        let path = native(&[".", "docs", "guide.md"]);
365        assert_eq!(path_to_url(&path), "docs/guide.md");
366    }
367
368    #[test]
369    fn test_root_component_is_dropped() {
370        // Absolute input still yields a relative body; callers add the leading
371        // slash themselves.
372        assert_eq!(path_to_url(Path::new("/docs/guide.md")), "docs/guide.md");
373    }
374
375    #[test]
376    fn test_spaces_and_unicode_preserved() {
377        let path = native(&["my images", "café \u{1f600}.png"]);
378        assert_eq!(path_to_url(&path), "my images/café \u{1f600}.png");
379    }
380
381    #[test]
382    fn test_no_trailing_or_leading_slash() {
383        let url = path_to_url(&native(&["docs", "guide.md"]));
384        assert!(!url.starts_with('/'));
385        assert!(!url.ends_with('/'));
386    }
387
388    /// On Windows a literal backslash string really is a separator, so this
389    /// asserts the conversion directly. It cannot run on Unix, where `\` is a
390    /// legal filename character and the same literal is a single component.
391    #[cfg(windows)]
392    #[test]
393    fn test_windows_backslash_literal_is_converted() {
394        assert_eq!(path_to_url(Path::new(r"docs\guide.md")), "docs/guide.md");
395        assert_eq!(
396            path_to_url(Path::new(r"C:\notes\docs\guide.md")),
397            "notes/docs/guide.md"
398        );
399    }
400
401    /// Conversely, on Unix a backslash is part of the filename and must be
402    /// left alone rather than rewritten into a path separator.
403    #[cfg(unix)]
404    #[test]
405    fn test_unix_backslash_is_a_filename_character() {
406        assert_eq!(path_to_url(Path::new(r"weird\name.md")), r"weird\name.md");
407    }
408
409    /// The whole point of the shared predicate: one table, one answer. The
410    /// `ftp*`/`magnet`/`sms`/`callto`/`blob` rows are the regression — each
411    /// was missing from at least one of the four hand-rolled scheme lists this
412    /// function replaced, so those links were rewritten into relative paths
413    /// and reported as broken internal links.
414    #[test]
415    fn test_is_external_url_table() {
416        let cases: &[(&str, bool)] = &[
417            // Schemes that used to be missing from one or more copies.
418            ("ftp://ftp.example.com/pub/file.zip", true),
419            ("ftps://ftp.example.com/pub/file.zip", true),
420            ("magnet:?xt=urn:btih:c12fe1c06bba254a9dc9", true),
421            ("sms:+15555550123", true),
422            ("callto:+15555550123", true),
423            ("blob:http://localhost:5220/550e8400-e29b", true),
424            // Application schemes. `message:` is the shape that motivated the
425            // GUI navigation handler: mail clients address a message by its
426            // `Message-ID`, whose angle brackets arrive percent-encoded, and
427            // both the `//` and the bare forms are in the wild.
428            (
429                "message://%3CCAEn-OzgEreVRuNkdnb9gdFNLXByerYCLraYJGRvjSvXw9chVMQ@mail.gmail.com%3E",
430                true,
431            ),
432            ("message:%3Cabc123@mail.example.com%3E", true),
433            ("zoommtg://zoom.us/join?confno=1234567890", true),
434            ("x-devonthink-item://8A3B0C1D-2E4F", true),
435            // Percent-encoding does not change the answer either way: it is not
436            // a scheme character, so it can neither create nor hide one.
437            ("https://example.com/a%20b?c=%26d#e%2Ff", true),
438            ("docs/my%20file.md", false),
439            ("/docs/caf%C3%A9/", false),
440            ("./%3Cbrackets%3E.md", false),
441            // An encoded colon is just a character in a filename.
442            ("Notes%3A2024.md", false),
443            // Schemes every copy already knew about.
444            ("mailto:test@example.com", true),
445            ("tel:+15555550123", true),
446            ("http://example.com/path", true),
447            ("https://example.com/path", true),
448            ("data:image/png;base64,iVBORw0KGgo", true),
449            ("javascript:void(0)", true),
450            ("file:///etc/hosts", true),
451            // Protocol-relative: no scheme named, still leaves the site.
452            ("//cdn.example.com/script.js", true),
453            // Site-relative shapes.
454            ("/abs/path", false),
455            ("relative/path.md", false),
456            ("./relative/path.md", false),
457            ("../sibling/path.md", false),
458            ("#anchor", false),
459            ("?q=1", false),
460            ("", false),
461            // A Windows drive letter is not a one-letter scheme.
462            ("C:/win/path", false),
463            ("c:/win/path", false),
464            // A colon in a later path segment does not make a scheme.
465            ("docs/a:b.md", false),
466            ("docs/a?x:y", false),
467            // Digits may not start a scheme (`12:30 notes.md` is a filename).
468            ("12:30 notes.md", false),
469            // RFC 3986's escape hatch for a colon in the first segment.
470            ("./Notes:2024.md", false),
471        ];
472
473        for (url, expected) in cases {
474            assert_eq!(
475                is_external_url(url),
476                *expected,
477                "is_external_url({url:?}) should be {expected}"
478            );
479        }
480    }
481    /// The exemption has to cover every spelling the pipeline emits and
482    /// nothing beyond it, so both halves of the predicate are pinned here:
483    /// a `.mbr` path segment *and* a `path=` query parameter.
484    #[test]
485    fn test_is_mbr_query_route_table() {
486        let cases: &[(&str, bool)] = &[
487            // The three spellings of one link: site-absolute (server mode),
488            // root-relative and nested-relative (static build).
489            ("/.mbr/videos/?path=%2Fvideos%2Fdemo.mp4", true),
490            (".mbr/videos/?path=%2Fvideos%2Fdemo.mp4", true),
491            ("../.mbr/videos/?path=%2Fvideos%2Fdemo.mp4", true),
492            ("../../.mbr/audio/?path=/music/track.mp3", true),
493            // All four viewer routes, plus the slashless spelling a hand
494            // author writes (which is what the build used to flag).
495            ("/.mbr/pdfs/?path=/docs/report.pdf", true),
496            ("/.mbr/images/?path=/img/photo.png", true),
497            ("/.mbr/videos?path=/videos/demo.mp4", true),
498            // Extra parameters and ordering do not change the answer.
499            ("/.mbr/videos/?t=30&path=/videos/demo.mp4", true),
500            ("/.mbr/videos/?path=/videos/demo.mp4#t=30", true),
501            // A `.mbr` route with no query is a genuinely broken link and
502            // stays reportable — that is what keeps this narrow.
503            ("/.mbr/videos/", false),
504            ("/.mbr/whatever/", false),
505            // An unrelated query on an mbr route is not a viewer link.
506            ("/.mbr/videos/?v=1", false),
507            // `path` must be a parameter, not a prefix of one.
508            ("/.mbr/videos/?pathological=1", false),
509            // A `path=` query on an ordinary page is an ordinary page link.
510            ("/docs/page/?path=x", false),
511            // `.mbr` must be a whole segment, not a substring.
512            ("/notes/.mbrconfig/?path=x", false),
513            ("/my.mbr/videos/?path=x", false),
514            // `#` closes the URI, so this `?path=` is fragment text and there
515            // is no query at all.
516            ("/.mbr/videos/#frag?path=/videos/demo.mp4", false),
517            // Fragment-only and empty hrefs.
518            ("#section", false),
519            ("", false),
520        ];
521
522        for (url, expected) in cases {
523            assert_eq!(
524                is_mbr_query_route(url),
525                *expected,
526                "is_mbr_query_route({url:?}) should be {expected}"
527            );
528        }
529    }
530
531    // ============ srcset ============
532
533    fn urls(srcset: &str) -> Vec<&str> {
534        srcset_url_spans(srcset)
535            .into_iter()
536            .map(|span| &srcset[span])
537            .collect()
538    }
539
540    #[test]
541    fn srcset_single_url() {
542        assert_eq!(urls("docs/hero-dark.png"), ["docs/hero-dark.png"]);
543    }
544
545    #[test]
546    fn srcset_url_with_density_descriptor() {
547        assert_eq!(urls("a.png 2x"), ["a.png"]);
548    }
549
550    #[test]
551    fn srcset_multiple_candidates_with_width_descriptors() {
552        assert_eq!(
553            urls("small.jpg 480w, medium.jpg 800w, large.jpg 1200w"),
554            ["small.jpg", "medium.jpg", "large.jpg"]
555        );
556    }
557
558    #[test]
559    fn srcset_extra_whitespace_and_newlines() {
560        let value = "\n    a.png   1x ,\n\t b.png\t2x\n  ";
561        assert_eq!(urls(value), ["a.png", "b.png"]);
562        assert_eq!(
563            rewrite_srcset(value, |url| Some(format!("../{url}"))),
564            "\n    ../a.png   1x ,\n\t ../b.png\t2x\n  "
565        );
566    }
567
568    #[test]
569    fn srcset_trailing_comma_is_a_separator() {
570        assert_eq!(urls("a.png,"), ["a.png"]);
571        assert_eq!(urls("a.png, b.png"), ["a.png", "b.png"]);
572        assert_eq!(urls("a.png,, b.png 2x,"), ["a.png", "b.png"]);
573        assert_eq!(
574            rewrite_srcset("a.png,b.png 2x", |url| Some(format!("/{url}"))),
575            "/a.png,b.png 2x",
576            "a comma inside the run is part of the URL"
577        );
578        assert_eq!(
579            rewrite_srcset("a.png, b.png 2x", |url| Some(format!("/{url}"))),
580            "/a.png, /b.png 2x"
581        );
582    }
583
584    #[test]
585    fn srcset_leading_commas_are_skipped() {
586        assert_eq!(urls(" ,, a.png 1x"), ["a.png"]);
587    }
588
589    #[test]
590    fn srcset_descriptor_parentheses_hide_commas() {
591        assert_eq!(urls("a.png (x, y), b.png"), ["a.png", "b.png"]);
592    }
593
594    #[test]
595    fn srcset_empty_and_blank() {
596        for value in ["", "   ", ",", " , \n"] {
597            assert!(urls(value).is_empty(), "{value:?}");
598            assert!(
599                matches!(rewrite_srcset(value, |_| Some("x".into())), Cow::Borrowed(v) if v == value)
600            );
601        }
602    }
603
604    #[test]
605    fn srcset_unchanged_is_borrowed() {
606        let value = "a.png 1x, b.png 2x";
607        assert!(matches!(rewrite_srcset(value, |_| None), Cow::Borrowed(v) if v == value));
608    }
609
610    #[test]
611    fn srcset_rewrites_only_chosen_urls() {
612        assert_eq!(
613            rewrite_srcset("https://x/a.png 1x, b.png 2x", |url| {
614                (!url.starts_with("https:")).then(|| format!("cid:{url}"))
615            }),
616            "https://x/a.png 1x, cid:b.png 2x"
617        );
618    }
619
620    proptest::proptest! {
621        /// Rebuilding with every URL replaced by itself gives the input back,
622        /// byte for byte: nothing outside the URLs is touched.
623        #[test]
624        fn srcset_identity_rebuild_is_byte_identical(value in "[a-z0-9./ ,\t\n()xw]{0,48}") {
625            let rebuilt = rewrite_srcset(&value, |url| Some(url.to_string()));
626            proptest::prop_assert_eq!(rebuilt.as_ref(), value.as_str());
627        }
628
629        /// Spans are ordered, disjoint, non-empty URLs with no whitespace and
630        /// no comma at either end.
631        #[test]
632        fn srcset_spans_are_well_formed(value in "[a-z0-9./ ,\t\n()xw]{0,48}") {
633            let spans = srcset_url_spans(&value);
634            let mut previous_end = 0;
635            for span in spans {
636                proptest::prop_assert!(span.start >= previous_end && span.start < span.end);
637                let url = &value[span.clone()];
638                proptest::prop_assert!(!url.bytes().any(is_html_whitespace), "{:?}", url);
639                proptest::prop_assert!(!url.starts_with(',') && !url.ends_with(','), "{:?}", url);
640                previous_end = span.end;
641            }
642        }
643    }
644}