Skip to main content

provenant/utils/
font.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::BTreeSet;
5use std::path::Path;
6
7use allsorts::binary::read::ReadScope;
8use allsorts::font_data::FontData;
9use allsorts::tables::{FontTableProvider, NameTable, OpenTypeData};
10use ttf_parser::{Face, Permissions, fonts_in_collection, name_id};
11
12use crate::parsers::metadata::ParserMetadata;
13
14pub(crate) const SUPPORTED_FONT_EXTENSIONS: &[&str] =
15    &["ttf", "otf", "woff", "woff2", "eot", "ttc", "otc"];
16pub(crate) const SUPPORTED_FONT_FILE_GLOBS: &[&str] = &[
17    "**/*.ttf",
18    "**/*.otf",
19    "**/*.woff",
20    "**/*.woff2",
21    "**/*.eot",
22    "**/*.ttc",
23    "**/*.otc",
24];
25const OFL_URL_CANONICALIZATIONS: &[(&str, &str)] = &[
26    ("https://scripts.sil.org/OFL/", "http://scripts.sil.org/OFL"),
27    ("https://scripts.sil.org/OFL", "http://scripts.sil.org/OFL"),
28    ("https://openfontlicense.org/", "http://scripts.sil.org/OFL"),
29    ("https://openfontlicense.org", "http://scripts.sil.org/OFL"),
30];
31const ALLSORTS_NAME_TABLE_TAG: u32 = u32::from_be_bytes(*b"name");
32
33pub(crate) static FONT_METADATA: &[ParserMetadata] = &[ParserMetadata {
34    description: "Embedded font legal metadata (native fonts, webfonts, and collections)",
35    file_patterns: SUPPORTED_FONT_FILE_GLOBS,
36    package_type: "",
37    primary_language: "",
38    documentation_url: Some("https://learn.microsoft.com/en-us/typography/opentype/spec/name"),
39}];
40
41pub(crate) fn is_supported_font_extension(extension: &str) -> bool {
42    SUPPORTED_FONT_EXTENSIONS
43        .iter()
44        .any(|supported| supported.eq_ignore_ascii_case(extension))
45}
46
47pub(crate) fn is_supported_font_path(path: &Path) -> bool {
48    path.extension()
49        .and_then(|ext| ext.to_str())
50        .is_some_and(is_supported_font_extension)
51}
52
53pub(crate) fn extract_font_metadata_text(path: &Path, bytes: &[u8]) -> Option<String> {
54    let extension = path.extension().and_then(|ext| ext.to_str())?;
55    let extension = extension.to_ascii_lowercase();
56    if !is_supported_font_extension(&extension) {
57        return None;
58    }
59
60    match extension.as_str() {
61        "ttf" | "otf" | "woff" | "woff2" | "ttc" | "otc" => extract_sfnt_font_metadata_text(
62            bytes,
63            matches!(extension.as_str(), "ttf" | "otf" | "ttc" | "otc"),
64        ),
65        "eot" => extract_eot_metadata_text(bytes),
66        _ => None,
67    }
68}
69
70fn extract_sfnt_font_metadata_text(bytes: &[u8], include_permissions: bool) -> Option<String> {
71    let mut lines = Vec::new();
72    let mut seen = BTreeSet::new();
73
74    for line in extract_allsorts_name_table_lines(bytes) {
75        if seen.insert(line.clone()) {
76            lines.push(line);
77        }
78    }
79
80    if include_permissions {
81        let face_count = fonts_in_collection(bytes).unwrap_or(1);
82        for face_index in 0..face_count {
83            let Some(permissions) = Face::parse(bytes, face_index).ok()?.permissions() else {
84                continue;
85            };
86            let line = format!(
87                "Embedding permissions: {}",
88                font_permission_label(permissions)
89            );
90            if seen.insert(line.clone()) {
91                lines.push(line);
92            }
93        }
94    }
95
96    (!lines.is_empty()).then(|| lines.join("\n"))
97}
98
99/// Decode every `name` table record into its own newline-separated line.
100///
101/// Each record is decoded from its own `offset`/`length` slice of the name
102/// table's string storage, so adjacent records never run together. This is the
103/// safe alternative to scraping raw printable strings from the font binary,
104/// where packed UTF-16 name-table storage glues consecutive records (e.g.
105/// designer, vendor URL, description) into run-on tokens such as
106/// `bulenkovhttps://www.jetbrains.comThis`, corrupting downstream URL and
107/// copyright extraction.
108pub(crate) fn extract_font_name_table_strings(bytes: &[u8]) -> String {
109    let mut lines = Vec::new();
110    let mut seen = BTreeSet::new();
111    for line in extract_allsorts_all_name_strings(bytes) {
112        let normalized = line.split_whitespace().collect::<Vec<_>>().join(" ");
113        if normalized.is_empty() {
114            continue;
115        }
116        if seen.insert(normalized.clone()) {
117            lines.push(normalized);
118        }
119    }
120    lines.join("\n")
121}
122
123/// Read each face's `name` table from an SFNT/WOFF/collection and hand it to
124/// `visit`. Centralizes the `ReadScope` → `FontData` → face loop → provider →
125/// `NameTable` setup shared by the name-table extractors so the reading logic
126/// (tag constant, error handling, allsorts API) lives in one place.
127fn for_each_name_table(bytes: &[u8], mut visit: impl FnMut(&NameTable<'_>)) {
128    let Some(font_data) = ReadScope::new(bytes).read::<FontData<'_>>().ok() else {
129        return;
130    };
131
132    for face_index in 0..allsorts_face_count(&font_data) {
133        let Ok(provider) = font_data.table_provider(face_index) else {
134            continue;
135        };
136        let Ok(name_table_data) = provider.read_table_data(ALLSORTS_NAME_TABLE_TAG) else {
137            continue;
138        };
139        let Ok(name_table) = ReadScope::new(name_table_data.as_ref()).read::<NameTable<'_>>()
140        else {
141            continue;
142        };
143        visit(&name_table);
144    }
145}
146
147fn extract_allsorts_all_name_strings(bytes: &[u8]) -> Vec<String> {
148    let mut strings = Vec::new();
149    for_each_name_table(bytes, |name_table| {
150        // Decode each distinct name id via `string_for_id`, which reads each
151        // record from its own offset/length rather than concatenating storage.
152        let mut name_ids = BTreeSet::new();
153        for record in name_table.name_records.iter() {
154            name_ids.insert(record.name_id);
155        }
156        for name_id in name_ids {
157            if let Some(value) = name_table.string_for_id(name_id) {
158                strings.push(value);
159            }
160        }
161    });
162    strings
163}
164
165fn extract_allsorts_name_table_lines(bytes: &[u8]) -> Vec<String> {
166    let mut lines = Vec::new();
167    let mut seen = BTreeSet::new();
168    for_each_name_table(bytes, |name_table| {
169        for (source_name_id, target_name_id) in [
170            (NameTable::COPYRIGHT_NOTICE, name_id::COPYRIGHT_NOTICE),
171            (NameTable::LICENSE_DESCRIPTION, name_id::LICENSE),
172            (NameTable::LICENSE_INFO_URL, name_id::LICENSE_URL),
173        ] {
174            let Some(value) = name_table.string_for_id(source_name_id) else {
175                continue;
176            };
177            let Some(line) = build_font_metadata_line(target_name_id, value) else {
178                continue;
179            };
180            if seen.insert(line.clone()) {
181                lines.push(line);
182            }
183        }
184    });
185    lines
186}
187
188fn allsorts_face_count(font_data: &FontData<'_>) -> usize {
189    match font_data {
190        FontData::OpenType(font) => match &font.data {
191            OpenTypeData::Single(_) => 1,
192            OpenTypeData::Collection(ttc) => ttc.offset_tables.len(),
193        },
194        FontData::Woff(_) => 1,
195        FontData::Woff2(font) => font
196            .collection_directory
197            .as_ref()
198            .map(|directory| directory.fonts().count())
199            .unwrap_or(1),
200    }
201}
202
203fn extract_eot_metadata_text(bytes: &[u8]) -> Option<String> {
204    let text = extract_eot_utf16le_marker_text(bytes).join("\n");
205    if text.is_empty() {
206        return None;
207    }
208
209    let mut lines = Vec::new();
210    let mut seen = BTreeSet::new();
211    for segment in split_eot_legal_metadata_segments(&text) {
212        let normalized = normalize_eot_metadata_segment(&segment);
213        if normalized.is_empty() {
214            continue;
215        }
216        if seen.insert(normalized.clone()) {
217            lines.push(normalized);
218        }
219    }
220
221    (!lines.is_empty()).then(|| lines.join("\n"))
222}
223
224fn extract_eot_utf16le_marker_text(bytes: &[u8]) -> Vec<String> {
225    let mut lines = Vec::new();
226    let mut seen = BTreeSet::new();
227    for marker in [
228        "Copyright",
229        "This Font Software is licensed under",
230        "http://",
231        "https://",
232    ] {
233        let encoded = marker.encode_utf16().collect::<Vec<_>>();
234        let marker_bytes = encoded
235            .iter()
236            .flat_map(|unit| unit.to_le_bytes())
237            .collect::<Vec<_>>();
238        let mut search_start = 0;
239        while let Some(relative_start) = bytes[search_start..]
240            .windows(marker_bytes.len())
241            .position(|window| window == marker_bytes.as_slice())
242        {
243            let start = search_start + relative_start;
244            let decoded = decode_utf16le_ascii_from_offset(bytes, start);
245            if !decoded.is_empty() && seen.insert(decoded.clone()) {
246                lines.push(decoded);
247            }
248            search_start = start + marker_bytes.len();
249        }
250    }
251    lines
252}
253
254fn decode_utf16le_ascii_from_offset(bytes: &[u8], start: usize) -> String {
255    let mut decoded = Vec::new();
256    let mut index = start;
257    while index + 1 < bytes.len() {
258        let lo = bytes[index];
259        let hi = bytes[index + 1];
260        if hi == 0 && (0x20..=0x7E).contains(&lo) {
261            decoded.push(lo);
262            index += 2;
263            continue;
264        }
265        break;
266    }
267    String::from_utf8_lossy(&decoded).into_owned()
268}
269
270fn split_eot_legal_metadata_segments(text: &str) -> Vec<String> {
271    let mut segments = Vec::new();
272
273    if let Some(segment) = extract_text_between_markers(
274        text,
275        "Copyright",
276        &["All Rights Reserved.", "All rights reserved."],
277    ) {
278        segments.push(segment);
279    }
280    if let Some(segment) = extract_text_between_markers(
281        text,
282        "This Font Software is licensed under",
283        &[
284            "governing your use of this Font Software.",
285            "This Font Software.",
286        ],
287    ) {
288        segments.push(segment);
289    }
290    segments.extend(extract_http_segments(text));
291
292    segments
293}
294
295fn extract_text_between_markers(
296    text: &str,
297    start_marker: &str,
298    end_markers: &[&str],
299) -> Option<String> {
300    let start = text.find(start_marker)?;
301    let tail = &text[start..];
302    let end = end_markers
303        .iter()
304        .filter_map(|marker| tail.find(marker).map(|idx| idx + marker.len()))
305        .min()
306        .unwrap_or(tail.len());
307    Some(tail[..end].to_string())
308}
309
310fn extract_http_segments(text: &str) -> Vec<String> {
311    let mut segments = Vec::new();
312    for marker in ["http://", "https://"] {
313        let mut search_start = 0;
314        while let Some(relative_start) = text[search_start..].find(marker) {
315            let start = search_start + relative_start;
316            let tail = &text[start + marker.len()..];
317            let mut end = text.len();
318            for boundary in [
319                "http://",
320                "https://",
321                "This Font Software",
322                "Copyright",
323                "Version ",
324            ] {
325                if let Some(relative_end) = tail.find(boundary) {
326                    end = end.min(start + marker.len() + relative_end);
327                }
328            }
329            if let Some(relative_end) = tail.find(char::is_whitespace) {
330                end = end.min(start + marker.len() + relative_end);
331            }
332
333            let segment = text[start..end]
334                .trim_end_matches(&['.', ',', ';', ':'][..])
335                .to_string();
336            if !segment.is_empty() {
337                segments.push(segment);
338            }
339            search_start = end.max(start + marker.len());
340        }
341    }
342    segments
343}
344
345fn normalize_eot_metadata_segment(segment: &str) -> String {
346    let normalized = segment
347        .split_whitespace()
348        .collect::<Vec<_>>()
349        .join(" ")
350        .trim()
351        .to_string();
352
353    if normalized.is_empty() {
354        return normalized;
355    }
356
357    let lowered = normalized.to_ascii_lowercase();
358    if lowered.starts_with("http://") || lowered.starts_with("https://") {
359        return canonicalize_ofl_license_reference_urls(normalized);
360    }
361
362    if lowered.contains("font software") || lowered.contains("open font license") {
363        return canonicalize_ofl_license_reference_urls(normalized);
364    }
365
366    normalized
367}
368
369fn build_font_metadata_line(name_id_value: u16, value: String) -> Option<String> {
370    let value = normalize_font_value(name_id_value, value);
371    if value.is_empty() {
372        return None;
373    }
374
375    if name_id_value == name_id::COPYRIGHT_NOTICE {
376        return Some(value);
377    }
378
379    let label = font_name_label(name_id_value)?;
380    Some(format!("{label}: {value}"))
381}
382
383fn font_name_label(name_id_value: u16) -> Option<&'static str> {
384    match name_id_value {
385        name_id::LICENSE => Some("License Description"),
386        name_id::LICENSE_URL => Some("License Info URL"),
387        _ => None,
388    }
389}
390
391fn normalize_font_value(name_id_value: u16, value: String) -> String {
392    let normalized = value
393        .split_whitespace()
394        .collect::<Vec<_>>()
395        .join(" ")
396        .trim()
397        .to_string();
398
399    match name_id_value {
400        name_id::COPYRIGHT_NOTICE => strip_reserved_font_name_clause(normalized),
401        name_id::LICENSE | name_id::LICENSE_URL => {
402            canonicalize_ofl_license_reference_urls(normalized)
403        }
404        _ => normalized,
405    }
406}
407
408fn strip_reserved_font_name_clause(value: String) -> String {
409    let lower = value.to_ascii_lowercase();
410    for marker in [
411        ", with reserved font name",
412        ", with no reserved font name",
413        " with reserved font name",
414        " with no reserved font name",
415    ] {
416        if let Some(index) = lower.find(marker) {
417            return value[..index]
418                .trim_end_matches(&[',', ';', ':', ' ', '('][..])
419                .trim()
420                .to_string();
421        }
422    }
423
424    value
425}
426
427fn canonicalize_ofl_license_reference_urls(mut value: String) -> String {
428    for (from, to) in OFL_URL_CANONICALIZATIONS {
429        value = value.replace(from, to);
430    }
431    value
432}
433
434fn font_permission_label(permission: Permissions) -> &'static str {
435    match permission {
436        Permissions::Installable => "Installable",
437        Permissions::Restricted => "Restricted",
438        Permissions::PreviewAndPrint => "Preview and Print",
439        Permissions::Editable => "Editable",
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use std::fs;
446    use std::path::Path;
447
448    use crate::copyright::detect_copyrights;
449    use crate::license_detection::LicenseDetectionEngine;
450    use ttf_parser::name_id;
451
452    use crate::finder::{DetectionConfig, find_urls};
453
454    use super::{
455        build_font_metadata_line, canonicalize_ofl_license_reference_urls,
456        extract_font_metadata_text, extract_font_name_table_strings,
457    };
458
459    #[test]
460    fn extracts_ofl_metadata_from_lato_font_fixture() {
461        let bytes =
462            fs::read("testdata/font-fixtures/Lato-Bold.ttf").expect("read lato font fixture");
463
464        let text = extract_font_metadata_text(Path::new("Lato-Bold.ttf"), &bytes)
465            .expect("font metadata text");
466
467        assert!(text.contains("License Description:"), "{text}");
468        assert!(
469            text.contains("Open Font License") || text.contains("OFL"),
470            "{text}"
471        );
472    }
473
474    #[test]
475    fn extracts_apache_metadata_from_underline_test_font_fixture() {
476        let bytes = fs::read("testdata/font-fixtures/UnderlineTest-Close.ttf")
477            .expect("read apache font fixture");
478
479        let text = extract_font_metadata_text(Path::new("UnderlineTest-Close.ttf"), &bytes)
480            .expect("font metadata text");
481
482        assert!(
483            text.contains("License Description:") || text.contains("Copyright"),
484            "{text}"
485        );
486        assert!(
487            text.contains("Apache") || text.contains("http://www.apache.org/licenses"),
488            "{text}"
489        );
490    }
491
492    #[test]
493    fn canonicalizes_ofl_url_variants_in_font_license_metadata() {
494        let canonical = canonicalize_ofl_license_reference_urls(
495            "This license is available with a FAQ at: https://openfontlicense.org/".to_string(),
496        );
497
498        assert_eq!(
499            canonical,
500            "This license is available with a FAQ at: http://scripts.sil.org/OFL"
501        );
502    }
503
504    #[test]
505    fn font_metadata_lines_detect_noto_ofl_text_without_trademark_noise() {
506        let metadata_text = [
507            build_font_metadata_line(
508                name_id::COPYRIGHT_NOTICE,
509                "Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)".to_string(),
510            ),
511            build_font_metadata_line(
512                name_id::TRADEMARK,
513                "Noto is a trademark of Google LLC.".to_string(),
514            ),
515            build_font_metadata_line(
516                name_id::LICENSE,
517                "This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is available with a FAQ at: https://scripts.sil.org/OFL".to_string(),
518            ),
519            build_font_metadata_line(
520                name_id::LICENSE_URL,
521                "https://scripts.sil.org/OFL".to_string(),
522            ),
523        ]
524        .into_iter()
525        .flatten()
526        .collect::<Vec<_>>()
527        .join("\n");
528
529        assert!(!metadata_text.contains("Trademark:"), "{metadata_text}");
530        assert!(
531            metadata_text.contains("Copyright 2022 The Noto Project Authors"),
532            "{metadata_text}"
533        );
534        assert!(
535            metadata_text.contains("http://scripts.sil.org/OFL"),
536            "{metadata_text}"
537        );
538
539        let engine = LicenseDetectionEngine::from_embedded().expect("initialize license engine");
540        let detections = engine
541            .detect_with_kind_and_source_with_score(&metadata_text, false, false, "font.ttf", 0.0)
542            .expect("detect licenses from font metadata text");
543
544        assert!(
545            detections.iter().any(|detection| {
546                detection
547                    .license_expression_spdx
548                    .as_deref()
549                    .is_some_and(|expression| expression.contains("OFL-1.1"))
550            }),
551            "detections: {detections:#?}"
552        );
553
554        let (copyrights, holders, _authors) = detect_copyrights(&metadata_text, None);
555        assert!(
556            copyrights.iter().any(|detection| {
557                detection.copyright
558                    == "Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)"
559            }),
560            "copyrights: {copyrights:#?}"
561        );
562        assert!(
563            holders
564                .iter()
565                .any(|detection| detection.holder == "The Noto Project Authors"),
566            "holders: {holders:#?}"
567        );
568    }
569
570    #[test]
571    fn extracts_metadata_from_sourcecodepro_woff_fixture() {
572        let bytes = fs::read("testdata/font-fixtures/SourceCodePro-Regular.otf.woff")
573            .expect("read woff font fixture");
574
575        let text = extract_font_metadata_text(Path::new("SourceCodePro-Regular.otf.woff"), &bytes)
576            .expect("woff font metadata text");
577
578        assert!(text.contains("Adobe"), "{text}");
579        assert!(
580            text.contains("Open Font License") || text.contains("OFL"),
581            "{text}"
582        );
583        assert!(text.contains("http://scripts.sil.org/OFL"), "{text}");
584    }
585
586    #[test]
587    fn extracts_metadata_from_sourcecodepro_woff2_fixture() {
588        let bytes = fs::read("testdata/font-fixtures/SourceCodePro-Regular.otf.woff2")
589            .expect("read woff2 font fixture");
590
591        let text = extract_font_metadata_text(Path::new("SourceCodePro-Regular.otf.woff2"), &bytes)
592            .expect("woff2 font metadata text");
593
594        assert!(text.contains("Adobe"), "{text}");
595        assert!(
596            text.contains("Open Font License") || text.contains("OFL"),
597            "{text}"
598        );
599        assert!(text.contains("http://scripts.sil.org/OFL"), "{text}");
600    }
601
602    #[test]
603    fn extracts_legal_strings_from_notosans_eot_fixture() {
604        let bytes =
605            fs::read("testdata/font-fixtures/NotoSans-Regular.eot").expect("read eot font fixture");
606
607        let text = extract_font_metadata_text(Path::new("NotoSans-Regular.eot"), &bytes)
608            .expect("eot font metadata text");
609
610        assert!(text.contains("Copyright 2015 Google Inc."), "{text}");
611        assert!(
612            text.contains("This Font Software is licensed under the SIL Open Font License"),
613            "{text}"
614        );
615        assert!(text.contains("http://scripts.sil.org/OFL"), "{text}");
616    }
617
618    #[test]
619    fn wrapped_font_metadata_detects_sourcecodepro_ofl_without_reserved_font_tail() {
620        let bytes = fs::read("testdata/font-fixtures/SourceCodePro-Regular.otf.woff")
621            .expect("read woff font fixture");
622        let metadata_text =
623            extract_font_metadata_text(Path::new("SourceCodePro-Regular.otf.woff"), &bytes)
624                .expect("wrapped font metadata text");
625
626        let engine = LicenseDetectionEngine::from_embedded().expect("initialize license engine");
627        let detections = engine
628            .detect_with_kind_and_source_with_score(&metadata_text, false, false, "font.woff", 0.0)
629            .expect("detect licenses from wrapped font metadata text");
630        assert!(
631            detections.iter().any(|detection| {
632                detection
633                    .license_expression_spdx
634                    .as_deref()
635                    .is_some_and(|expression| expression.contains("OFL-1.1"))
636            }),
637            "detections: {detections:#?}"
638        );
639
640        let (copyrights, holders, _authors) = detect_copyrights(&metadata_text, None);
641        assert!(
642            copyrights.iter().any(|detection| {
643                detection.copyright == "(c) 2023 Adobe (http://www.adobe.com/)"
644            }),
645            "copyrights: {copyrights:#?}"
646        );
647        assert!(
648            holders.iter().any(|detection| detection.holder == "Adobe"),
649            "holders: {holders:#?}"
650        );
651    }
652
653    #[test]
654    fn extracts_metadata_from_ttc_fixture() {
655        let bytes = fs::read("testdata/font-fixtures/TTC.ttc").expect("read ttc font fixture");
656
657        let text = extract_font_metadata_text(Path::new("TTC.ttc"), &bytes)
658            .expect("ttc font metadata text");
659
660        assert!(
661            text.contains("Copyright") || text.contains("License"),
662            "{text}"
663        );
664        assert!(text.contains("No rights reserved"), "{text}");
665    }
666
667    #[test]
668    fn name_table_strings_do_not_run_records_together_into_malformed_urls() {
669        // Reproduces the JetBrains variable-font name-table packing where
670        // designer, vendor URL, and description records are stored contiguously
671        // in UTF-16 storage with no separators. A raw whole-binary
672        // printable-strings scrape glued them into run-on URLs such as
673        // `bulenkovhttps://www.jetbrains.comThis`; per-record decoding keeps each
674        // value on its own line.
675        let bytes = fs::read("testdata/font-fixtures/SyntheticVariableNameRunon.ttf")
676            .expect("read synthetic variable font fixture");
677
678        let name_strings = extract_font_name_table_strings(&bytes);
679
680        assert!(
681            name_strings.contains("https://www.jetbrains.com"),
682            "{name_strings}"
683        );
684        // The vendor URL must stand alone, never fused with the adjacent
685        // designer or description records.
686        assert!(
687            !name_strings.contains("bulenkovhttps://www.jetbrains.com"),
688            "{name_strings}"
689        );
690        assert!(
691            !name_strings.contains("www.jetbrains.comThis"),
692            "{name_strings}"
693        );
694        assert!(
695            !name_strings.contains("OFLhttps://scripts.sil.org/OFL"),
696            "{name_strings}"
697        );
698
699        // The name-table strings feed URL detection downstream; assert no
700        // malformed run-on URL survives end-of-URL cleaning.
701        let urls = find_urls(&name_strings, &DetectionConfig::default());
702        let detected: Vec<&str> = urls.iter().map(|url| url.url.as_str()).collect();
703        assert!(
704            detected
705                .iter()
706                .any(|url| url.starts_with("https://www.jetbrains.com")),
707            "detected URLs: {detected:?}"
708        );
709        for url in &detected {
710            assert!(
711                !url.contains("comThis") && !url.contains("OFLhttps"),
712                "malformed run-on URL detected: {url:?} (all: {detected:?})"
713            );
714        }
715    }
716}