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}